From 9c4502a18b25115562d336b91cb8aa5d664fd431 Mon Sep 17 00:00:00 2001 From: Chris Caron Date: Sun, 3 Dec 2017 02:00:23 -0500 Subject: [PATCH] python v3 support + refactored testing and ci --- .coveragerc | 15 ++ .travis.yml | 44 +++++- README.md | 7 +- apprise/Apprise.py | 13 +- apprise/AppriseAsset.py | 2 +- apprise/__init__.py | 8 +- apprise/plugins/NotifyBase.py | 119 +++++++--------- apprise/plugins/NotifyBoxcar.py | 9 +- apprise/plugins/NotifyEmail.py | 22 +-- apprise/plugins/NotifyGrowl/NotifyGrowl.py | 4 +- apprise/plugins/NotifyJSON.py | 3 +- apprise/plugins/NotifyJoin.py | 13 +- apprise/plugins/NotifyMatterMost.py | 6 +- apprise/plugins/NotifyMyAndroid.py | 8 +- apprise/plugins/NotifyProwl.py | 5 +- apprise/plugins/NotifyPushBullet.py | 12 +- apprise/plugins/NotifyPushalot.py | 5 +- apprise/plugins/NotifyPushover.py | 16 ++- apprise/plugins/NotifyRocketChat.py | 13 +- apprise/plugins/NotifySlack.py | 10 +- apprise/plugins/NotifyTelegram.py | 11 +- apprise/plugins/NotifyToasty.py | 13 +- apprise/plugins/NotifyXML.py | 10 +- apprise/utils.py | 118 +++++++++------ dev-requirements.txt | 5 + requirements.txt | 2 - setup.cfg | 26 ++-- setup.py | 13 +- test/test_api.py | 91 +++++++++++- test/test_notifybase.py | 158 +++++++++++++++++++++ test/test_utils.py | 97 +++++++++++-- tox.ini | 52 +++++++ 32 files changed, 694 insertions(+), 236 deletions(-) create mode 100644 .coveragerc create mode 100644 dev-requirements.txt create mode 100644 test/test_notifybase.py create mode 100644 tox.ini diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..892b32bd --- /dev/null +++ b/.coveragerc @@ -0,0 +1,15 @@ +[run] +omit=*/gntp/*,*/tweepy/*,*/pushjet/* +disable_warnings = no-data-collected +branch = True +source = + apprise + +[paths] +source = + apprise + .tox/*/lib/python*/site-packages/apprise + .tox/pypy/site-packages/apprise + +[report] +show_missing = True diff --git a/.travis.yml b/.travis.yml index 331fe132..ec96f1ec 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,15 +1,49 @@ +dist: trusty +sudo: false +cache: + directories: + - $HOME/.cache/pip + + language: python -python: - - "2.7" + + +matrix: + include: + - python: "2.7" + env: TOXENV=py27 + - python: "3.4" + env: TOXENV=py34 + - python: "3.5" + env: TOXENV=py35 + - python: "3.6" + env: TOXENV=py36 + - python: "pypy2.7-5.8.0" + env: TOXENV=pypy + - python: "pypy3.5-5.8.0" +env: TOXENV=pypy3 + install: - pip install . - - pip install coveralls + - pip install tox + - pip install -r dev-requirements.txt - pip install -r requirements.txt + +before_install: + - pip install codecov + + after_success: - - coveralls + - tox -e coverage-report + - codecov + # run tests -script: nosetests --with-coverage --cover-package=apprise +script: + - tox + +notifications: + email: false diff --git a/README.md b/README.md index 2b1d5282..e31e4548 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +![Apprise Logo](http://repo.nuxref.com/pub/img/logo-apprise.png) +
**ap·prise** / *verb*
@@ -6,7 +8,8 @@ To inform or tell (someone). To make one aware of something. *Apprise* allows you to take advantage of *just about* every notification service available to us today. Send a notification to almost all of the most popular services out there today (such as Telegram, Slack, Twitter, etc). The ones that don't exist can be adapted and supported too! -[![Build Status](https://travis-ci.org/caronc/apprise.svg?branch=master)](https://travis-ci.org/caronc/apprise)[![Coverage Status](https://coveralls.io/repos/caronc/apprise/badge.svg?branch=master)](https://coveralls.io/r/caronc/apprise?branch=master) +[![Build Status](https://travis-ci.org/caronc/apprise.svg?branch=master)](https://travis-ci.org/caronc/apprise) +[![CodeCov Status](https://codecov.io/github/caronc/apprise/branch/master/graph/badge.svg)](https://codecov.io/github/caronc/apprise) [![Paypal](http://repo.nuxref.com/pub/img/paypaldonate.svg)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=MHANV39UZNQ5E) [![Patreon](http://repo.nuxref.com/pub/img/patreondonate.svg)](https://www.patreon.com/lead2gold) @@ -76,7 +79,7 @@ To send a notification from within your python application, just do the followin import apprise # create an Apprise instance -apobj = Apprise() +apobj = apprise.Apprise() # Add all of the notification services by their server url. # A sample email notification diff --git a/apprise/Apprise.py b/apprise/Apprise.py index 40790ca7..eb8c4f48 100644 --- a/apprise/Apprise.py +++ b/apprise/Apprise.py @@ -23,8 +23,8 @@ import re import logging from .common import NotifyType -from .common import NOTIFY_TYPES from .utils import parse_list +from .utils import compat_is_basestring from .AppriseAsset import AppriseAsset @@ -54,7 +54,7 @@ def __load_matrix(): # Load protocol(s) if defined proto = getattr(plugin, 'protocol', None) - if isinstance(proto, basestring): + if compat_is_basestring(proto): if proto not in SCHEMA_MAP: SCHEMA_MAP[proto] = plugin @@ -66,7 +66,7 @@ def __load_matrix(): # Load secure protocol(s) if defined protos = getattr(plugin, 'secure_protocol', None) - if isinstance(protos, basestring): + if compat_is_basestring(protos): if protos not in SCHEMA_MAP: SCHEMA_MAP[protos] = plugin @@ -191,9 +191,9 @@ class Apprise(object): """ self.servers[:] = [] - def notify(self, title, body, notify_type=NotifyType.SUCCESS, **kwargs): + def notify(self, title, body, notify_type=NotifyType.INFO): """ - This should be over-rided by the class that inherits this one. + Send a notification to all of the plugins previously loaded """ # Initialize our return result @@ -206,7 +206,8 @@ class Apprise(object): for server in self.servers: try: # Send notification - if not server.notify(title=title, body=body): + if not server.notify( + title=title, body=body, notify_type=notify_type): # Toggle our return status flag status = False diff --git a/apprise/AppriseAsset.py b/apprise/AppriseAsset.py index 2bd32c81..e345a0cf 100644 --- a/apprise/AppriseAsset.py +++ b/apprise/AppriseAsset.py @@ -142,6 +142,6 @@ class AppriseAsset(object): except (OSError, IOError): # We can't access the file - pass + return None return None diff --git a/apprise/__init__.py b/apprise/__init__.py index ea4cbb29..ba7b3d76 100644 --- a/apprise/__init__.py +++ b/apprise/__init__.py @@ -33,13 +33,7 @@ from .AppriseAsset import AppriseAsset # Set default logging handler to avoid "No handler found" warnings. import logging -try: # Python 2.7+ - from logging import NullHandler -except ImportError: - class NullHandler(logging.Handler): - def emit(self, record): - pass - +from logging import NullHandler logging.getLogger(__name__).addHandler(NullHandler()) __all__ = [ diff --git a/apprise/plugins/NotifyBase.py b/apprise/plugins/NotifyBase.py index 39e9d2dc..5bfa39fe 100644 --- a/apprise/plugins/NotifyBase.py +++ b/apprise/plugins/NotifyBase.py @@ -17,13 +17,19 @@ # GNU Lesser General Public License for more details. import re -import markdown import logging from time import sleep -from urllib import unquote as _unquote +try: + # Python 2.7 + from urllib import unquote as _unquote + from urllib import quote as _quote + from urllib import urlencode as _urlencode -# For conversion -from chardet import detect as chardet_detect +except ImportError: + # Python 3.x + from urllib.parse import unquote as _unquote + from urllib.parse import quote as _quote + from urllib.parse import urlencode as _urlencode from ..utils import parse_url from ..utils import parse_bool @@ -159,7 +165,7 @@ class NotifyBase(object): self.include_image = include_image self.secure = secure - if throttle: + if isinstance(throttle, (float, int)): # Custom throttle override self.throttle_attempt = throttle @@ -171,6 +177,7 @@ class NotifyBase(object): if self.port: try: self.port = int(self.port) + except (TypeError, ValueError): self.port = None @@ -238,86 +245,64 @@ class NotifyBase(object): image_size=self.image_size, ) - def escape_html(self, html, convert_new_lines=False): + @staticmethod + def escape_html(html, convert_new_lines=False): """ Takes html text as input and escapes it so that it won't conflict with any xml/html wrapping characters. """ escaped = _escape(html).\ replace(u'\t', u' ').\ - replace(u' ', u'  ') + replace(u' ', u' ') if convert_new_lines: - return escaped.replace(u'\n', u'
') + return escaped.replace(u'\n', u'<br/>') return escaped - def to_utf8(self, content): + @staticmethod + def unquote(content, encoding='utf-8', errors='replace'): """ - Attempts to convert non-utf8 content to... (you guessed it) utf8 + common unquote function + """ - if not content: - return '' - - if isinstance(content, unicode): - return content.encode('utf-8') - - result = chardet_detect(content) - encoding = result['encoding'] try: - content = content.decode( - encoding, - errors='replace', - ) - return content.encode('utf-8') - - except UnicodeError: - raise ValueError( - '%s contains invalid characters' % ( - content)) - - except KeyError: - raise ValueError( - '%s encoding could not be detected ' % ( - content)) + # Python v3.x + return _unquote(content, encoding=encoding, errors=errors) except TypeError: - try: - content = content.decode( - encoding, - 'replace', - ) - return content.encode('utf-8') + # Python v2.7 + return _unquote(content) - except UnicodeError: - raise ValueError( - '%s contains invalid characters' % ( - content)) - - except KeyError: - raise ValueError( - '%s encoding could not be detected ' % ( - content)) - - return '' - - def to_html(self, body): + @staticmethod + def quote(content, safe='/', encoding=None, errors=None): """ - Returns the specified title in an html format and factors - in a titles defined max length + common quote function + """ - html = markdown.markdown(body) + try: + # Python v3.x + return _quote(content, safe=safe, encoding=encoding, errors=errors) - # TODO: - # This function should return multiple messages if we exceed - # the maximum number of characters. the second message should + except TypeError: + # Python v2.7 + return _quote(content, safe=safe) - # The new message should factor in the title and add ' cont...' - # to the end of it. It should also include the added characters - # put in place by the html characters. So there is a little bit - # of math and manipulation that needs to go on here. - # we always return a list - return [html, ] + @staticmethod + def urlencode(query, doseq=False, safe='', encoding=None, errors=None): + """ + common urlencode function + + """ + try: + # Python v3.x + return _urlencode( + query, doseq=doseq, safe=safe, encoding=encoding, + errors=errors) + + except TypeError: + # Python v2.7 + return _urlencode(query, oseq=doseq) @staticmethod def split_path(path, unquote=True): @@ -326,7 +311,8 @@ class NotifyBase(object): """ if unquote: - return PATHSPLIT_LIST_DELIM.split(_unquote(path).lstrip('/')) + return PATHSPLIT_LIST_DELIM.split( + NotifyBase.unquote(path).lstrip('/')) return PATHSPLIT_LIST_DELIM.split(path.lstrip('/')) @staticmethod @@ -360,7 +346,8 @@ class NotifyBase(object): if 'qsd' in results: if 'verify' in results['qsd']: - parse_bool(results['qsd'].get('verify', True)) + results['verify'] = parse_bool( + results['qsd'].get('verify', True)) # Password overrides if 'pass' in results['qsd']: diff --git a/apprise/plugins/NotifyBoxcar.py b/apprise/plugins/NotifyBoxcar.py index a9c6289f..6ecd3d16 100644 --- a/apprise/plugins/NotifyBoxcar.py +++ b/apprise/plugins/NotifyBoxcar.py @@ -17,13 +17,14 @@ # GNU Lesser General Public License for more details. from json import dumps -from urllib import unquote import requests import re from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..utils import compat_is_basestring + # Used to validate Tags, Aliases and Devices IS_TAG = re.compile(r'^[A-Za-z0-9]{1,63}$') IS_ALIAS = re.compile(r'^[@]?[A-Za-z0-9]+$') @@ -70,12 +71,12 @@ class NotifyBoxcar(NotifyBase): if recipients is None: recipients = [] - elif isinstance(recipients, basestring): + elif compat_is_basestring(recipients): recipients = filter(bool, TAGS_LIST_DELIM.split( recipients, )) - elif not isinstance(recipients, (tuple, list)): + elif not isinstance(recipients, (set, tuple, list)): recipients = [] # Validate recipients and drop bad ones: @@ -189,7 +190,7 @@ class NotifyBoxcar(NotifyBase): # Acquire our recipients and include them in the response try: - recipients = unquote(results['fullpath']) + recipients = NotifyBase.unquote(results['fullpath']) except (AttributeError, KeyError): # no recipients detected diff --git a/apprise/plugins/NotifyEmail.py b/apprise/plugins/NotifyEmail.py index e2511263..c399a768 100644 --- a/apprise/plugins/NotifyEmail.py +++ b/apprise/plugins/NotifyEmail.py @@ -23,10 +23,9 @@ from smtplib import SMTP from smtplib import SMTPException from socket import error as SocketError -from urllib import unquote as unquote - from email.mime.text import MIMEText +from ..utils import compat_is_basestring from .NotifyBase import NotifyBase from .NotifyBase import NotifyFormat @@ -166,13 +165,13 @@ class NotifyEmail(NotifyBase): # Keep trying to be clever and make it equal to the to address self.from_addr = self.to_addr - if not isinstance(self.to_addr, basestring): + if not compat_is_basestring(self.to_addr): raise TypeError('No valid ~To~ email address specified.') if not NotifyBase.is_email(self.to_addr): raise TypeError('Invalid ~To~ email format: %s' % self.to_addr) - if not isinstance(self.from_addr, basestring): + if not compat_is_basestring(self.from_addr): raise TypeError('No valid ~From~ email address specified.') match = NotifyBase.is_email(self.from_addr) @@ -294,7 +293,7 @@ class NotifyEmail(NotifyBase): self.to_addr, )) - except (SocketError, SMTPException), e: + except (SocketError, SMTPException) as e: self.logger.warning( 'A Connection error occured sending Email ' 'notification to %s.' % self.smtp_host) @@ -336,7 +335,7 @@ class NotifyEmail(NotifyBase): if 'format' in results['qsd'] and len(results['qsd']['format']): # Extract email format (Text/Html) try: - format = unquote(results['qsd']['format']).lower() + format = NotifyBase.unquote(results['qsd']['format']).lower() if len(format) > 0 and format[0] == 't': results['notify_format'] = NotifyFormat.TEXT @@ -364,7 +363,7 @@ class NotifyEmail(NotifyBase): if not NotifyBase.is_email(to_addr): NotifyBase.logger.error( '%s does not contain a recipient email.' % - unquote(results['url'].lstrip('/')), + NotifyBase.unquote(results['url'].lstrip('/')), ) return None @@ -384,7 +383,7 @@ class NotifyEmail(NotifyBase): if not NotifyBase.is_email(from_addr): NotifyBase.logger.error( '%s does not contain a from address.' % - unquote(results['url'].lstrip('/')), + NotifyBase.unquote(results['url'].lstrip('/')), ) return None @@ -394,7 +393,7 @@ class NotifyEmail(NotifyBase): try: if 'name' in results['qsd'] and len(results['qsd']['name']): # Extract from name to associate with from address - results['name'] = unquote(results['qsd']['name']) + results['name'] = NotifyBase.unquote(results['qsd']['name']) except AttributeError: pass @@ -402,7 +401,8 @@ class NotifyEmail(NotifyBase): try: if 'timeout' in results['qsd'] and len(results['qsd']['timeout']): # Extract the timeout to associate with smtp server - results['timeout'] = unquote(results['qsd']['timeout']) + results['timeout'] = NotifyBase.unquote( + results['qsd']['timeout']) except AttributeError: pass @@ -411,7 +411,7 @@ class NotifyEmail(NotifyBase): try: # Extract from password to associate with smtp server if 'smtp' in results['qsd'] and len(results['qsd']['smtp']): - smtp_host = unquote(results['qsd']['smtp']) + smtp_host = NotifyBase.unquote(results['qsd']['smtp']) except AttributeError: pass diff --git a/apprise/plugins/NotifyGrowl/NotifyGrowl.py b/apprise/plugins/NotifyGrowl/NotifyGrowl.py index df20c0c6..3f90c7fa 100644 --- a/apprise/plugins/NotifyGrowl/NotifyGrowl.py +++ b/apprise/plugins/NotifyGrowl/NotifyGrowl.py @@ -17,7 +17,6 @@ # GNU Lesser General Public License for more details. import re -from urllib import unquote from .gntp.notifier import GrowlNotifier from .gntp.errors import NetworkError as GrowlNetworkError @@ -212,7 +211,8 @@ class NotifyGrowl(NotifyBase): # Allow the user to specify the version of the protocol to use. try: version = int( - unquote(results['qsd']['version']).strip().split('.')[0]) + NotifyBase.unquote( + results['qsd']['version']).strip().split('.')[0]) except (AttributeError, IndexError, TypeError, ValueError): NotifyBase.logger.warning( diff --git a/apprise/plugins/NotifyJSON.py b/apprise/plugins/NotifyJSON.py index a4f9135e..5b812c3b 100644 --- a/apprise/plugins/NotifyJSON.py +++ b/apprise/plugins/NotifyJSON.py @@ -22,6 +22,7 @@ from json import dumps from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..utils import compat_is_basestring # Image Support (128x128) JSON_IMAGE_XY = NotifyImageSize.XY_128 @@ -53,7 +54,7 @@ class NotifyJSON(NotifyBase): self.schema = 'http' self.fullpath = kwargs.get('fullpath') - if not isinstance(self.fullpath, basestring): + if not compat_is_basestring(self.fullpath): self.fullpath = '/' return diff --git a/apprise/plugins/NotifyJoin.py b/apprise/plugins/NotifyJoin.py index 186723f2..90d12b97 100644 --- a/apprise/plugins/NotifyJoin.py +++ b/apprise/plugins/NotifyJoin.py @@ -28,19 +28,20 @@ import re import requests -from urllib import urlencode from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..utils import compat_is_basestring # Token required as part of the API request VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{32}') # Extend HTTP Error Messages -JOIN_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { +JOIN_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +JOIN_HTTP_ERROR_MAP.update({ 401: 'Unauthorized - Invalid Token.', -}.items()) +}) # Used to break path apart into list of devices DEVICE_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') @@ -90,12 +91,12 @@ class NotifyJoin(NotifyBase): # The token associated with the account self.apikey = apikey.strip() - if isinstance(devices, basestring): + if compat_is_basestring(devices): self.devices = filter(bool, DEVICE_LIST_DELIM.split( devices, )) - elif isinstance(devices, (tuple, list)): + elif isinstance(devices, (set, tuple, list)): self.devices = devices else: @@ -158,7 +159,7 @@ class NotifyJoin(NotifyBase): payload = {} # Prepare the URL - url = '%s?%s' % (self.notify_url, urlencode(url_args)) + url = '%s?%s' % (self.notify_url, NotifyBase.urlencode(url_args)) self.logger.debug('Join POST URL: %s (cert_verify=%r)' % ( url, self.verify_certificate, diff --git a/apprise/plugins/NotifyMatterMost.py b/apprise/plugins/NotifyMatterMost.py index 217ed55b..24c8b1e2 100644 --- a/apprise/plugins/NotifyMatterMost.py +++ b/apprise/plugins/NotifyMatterMost.py @@ -19,7 +19,6 @@ import re import requests from json import dumps -from urllib import unquote as unquote from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP @@ -181,8 +180,7 @@ class NotifyMatterMost(NotifyBase): # Apply our settings now try: - authtoken = filter( - bool, NotifyBase.split_path(results['fullpath']))[0] + authtoken = NotifyBase.split_path(results['fullpath'])[0] except (AttributeError, IndexError): # Force some bad values that will get caught @@ -193,7 +191,7 @@ class NotifyMatterMost(NotifyBase): if 'channel' in results['qsd'] and len(results['qsd']['channel']): # Allow the user to specify the channel to post to try: - channel = unquote(results['qsd']['channel']).strip() + channel = NotifyBase.unquote(results['qsd']['channel']).strip() except (AttributeError, TypeError, ValueError): NotifyBase.logger.warning( diff --git a/apprise/plugins/NotifyMyAndroid.py b/apprise/plugins/NotifyMyAndroid.py index e0367e34..6d0c0665 100644 --- a/apprise/plugins/NotifyMyAndroid.py +++ b/apprise/plugins/NotifyMyAndroid.py @@ -18,18 +18,18 @@ import re import requests -from urllib import unquote from .NotifyBase import NotifyBase from .NotifyBase import NotifyFormat from .NotifyBase import HTTP_ERROR_MAP # Extend HTTP Error Messages -NMA_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { +NMA_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +NMA_HTTP_ERROR_MAP.update({ 400: 'Data is wrong format, invalid length or null.', 401: 'API Key provided is invalid', 402: 'Maximum number of API calls per hour reached.', -}.items()) +}) # Used to validate Authorization Token VALIDATE_APIKEY = re.compile(r'[A-Za-z0-9]{48}') @@ -185,7 +185,7 @@ class NotifyMyAndroid(NotifyBase): if 'format' in results['qsd'] and len(results['qsd']['format']): # Extract email format (Text/Html) try: - format = unquote(results['qsd']['format']).lower() + format = NotifyBase.unquote(results['qsd']['format']).lower() if len(format) > 0 and format[0] == 't': results['notify_format'] = NotifyFormat.TEXT diff --git a/apprise/plugins/NotifyProwl.py b/apprise/plugins/NotifyProwl.py index 874da793..e47dbb61 100644 --- a/apprise/plugins/NotifyProwl.py +++ b/apprise/plugins/NotifyProwl.py @@ -47,10 +47,11 @@ PROWL_PRIORITIES = ( ) # Extend HTTP Error Messages -PROWL_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { +PROWL_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +HTTP_ERROR_MAP.update({ 406: 'IP address has exceeded API limit', 409: 'Request not aproved.', -}.items()) +}) class NotifyProwl(NotifyBase): diff --git a/apprise/plugins/NotifyPushBullet.py b/apprise/plugins/NotifyPushBullet.py index eb2092e2..acdb9539 100644 --- a/apprise/plugins/NotifyPushBullet.py +++ b/apprise/plugins/NotifyPushBullet.py @@ -19,12 +19,13 @@ import re import requests from json import dumps -from urllib import unquote from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from .NotifyBase import IS_EMAIL_RE +from ..utils import compat_is_basestring + # Flag used as a placeholder to sending to all devices PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES' @@ -33,9 +34,10 @@ PUSHBULLET_SEND_TO_ALL = 'ALL_DEVICES' RECIPIENTS_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') # Extend HTTP Error Messages -PUSHBULLET_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { +PUSHBULLET_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +PUSHBULLET_HTTP_ERROR_MAP.update({ 401: 'Unauthorized - Invalid Token.', -}.items()) +}) class NotifyPushBullet(NotifyBase): @@ -60,7 +62,7 @@ class NotifyPushBullet(NotifyBase): title_maxlen=250, body_maxlen=32768, **kwargs) self.accesstoken = accesstoken - if isinstance(recipients, basestring): + if compat_is_basestring(recipients): self.recipients = filter(bool, RECIPIENTS_LIST_DELIM.split( recipients, )) @@ -178,7 +180,7 @@ class NotifyPushBullet(NotifyBase): # Apply our settings now try: - recipients = unquote(results['fullpath']) + recipients = NotifyBase.unquote(results['fullpath']) except AttributeError: recipients = '' diff --git a/apprise/plugins/NotifyPushalot.py b/apprise/plugins/NotifyPushalot.py index a6a91331..eb992102 100644 --- a/apprise/plugins/NotifyPushalot.py +++ b/apprise/plugins/NotifyPushalot.py @@ -28,10 +28,11 @@ from ..common import NotifyImageSize PUSHALOT_IMAGE_XY = NotifyImageSize.XY_72 # Extend HTTP Error Messages -PUSHALOT_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { +PUSHALOT_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +PUSHALOT_HTTP_ERROR_MAP.update({ 406: 'Message throttle limit hit.', 410: 'AuthorizedToken is no longer valid.', -}.items()) +}) # Used to validate Authorization Token VALIDATE_AUTHTOKEN = re.compile(r'[A-Za-z0-9]{32}') diff --git a/apprise/plugins/NotifyPushover.py b/apprise/plugins/NotifyPushover.py index 4cffef7e..d4aa9454 100644 --- a/apprise/plugins/NotifyPushover.py +++ b/apprise/plugins/NotifyPushover.py @@ -18,8 +18,8 @@ import re import requests -from urllib import unquote +from ..utils import compat_is_basestring from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP @@ -57,9 +57,10 @@ PUSHOVER_PRIORITIES = ( DEVICE_LIST_DELIM = re.compile(r'[ \t\r\n,\\/]+') # Extend HTTP Error Messages -PUSHOVER_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { +PUSHOVER_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +PUSHOVER_HTTP_ERROR_MAP.update({ 401: 'Unauthorized - Invalid Token.', -}.items()) +}) class NotifyPushover(NotifyBase): @@ -95,12 +96,14 @@ class NotifyPushover(NotifyBase): # The token associated with the account self.token = token.strip() - if isinstance(devices, basestring): + if compat_is_basestring(devices): self.devices = filter(bool, DEVICE_LIST_DELIM.split( devices, )) - elif isinstance(devices, (tuple, list)): + + elif isinstance(devices, (set, tuple, list)): self.devices = devices + else: self.devices = list() @@ -110,6 +113,7 @@ class NotifyPushover(NotifyBase): # The Priority of the message if priority not in PUSHOVER_PRIORITIES: self.priority = PushoverPriority.NORMAL + else: self.priority = priority @@ -230,7 +234,7 @@ class NotifyPushover(NotifyBase): # Apply our settings now try: - devices = unquote(results['fullpath']) + devices = NotifyBase.unquote(results['fullpath']) except AttributeError: devices = '' diff --git a/apprise/plugins/NotifyRocketChat.py b/apprise/plugins/NotifyRocketChat.py index 6915c549..7c83e935 100644 --- a/apprise/plugins/NotifyRocketChat.py +++ b/apprise/plugins/NotifyRocketChat.py @@ -19,19 +19,20 @@ import re import requests from json import loads -from urllib import unquote from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP +from ..utils import compat_is_basestring IS_CHANNEL = re.compile(r'^#(?P[A-Za-z0-9]+)$') IS_ROOM_ID = re.compile(r'^(?P[A-Za-z0-9]+)$') # Extend HTTP Error Messages -RC_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { +RC_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +RC_HTTP_ERROR_MAP.update({ 400: 'Channel/RoomId is wrong format, or missing from server.', 401: 'Authentication tokens provided is invalid or missing.', -}.items()) +}) # Used to break apart list of potential tags by their delimiter # into a usable list. @@ -79,12 +80,12 @@ class NotifyRocketChat(NotifyBase): if recipients is None: recipients = [] - elif isinstance(recipients, basestring): + elif compat_is_basestring(recipients): recipients = filter(bool, LIST_DELIM.split( recipients, )) - elif not isinstance(recipients, (tuple, list)): + elif not isinstance(recipients, (set, tuple, list)): recipients = [] # Validate recipients and drop bad ones: @@ -316,7 +317,7 @@ class NotifyRocketChat(NotifyBase): # Apply our settings now try: - results['recipients'] = unquote(results['fullpath']) + results['recipients'] = NotifyBase.unquote(results['fullpath']) except AttributeError: return None diff --git a/apprise/plugins/NotifySlack.py b/apprise/plugins/NotifySlack.py index 862b2f3e..b5d0373a 100644 --- a/apprise/plugins/NotifySlack.py +++ b/apprise/plugins/NotifySlack.py @@ -36,6 +36,7 @@ from time import time from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..utils import compat_is_basestring # Token required as part of the API request # /AAAAAAAAA/........./........................ @@ -53,9 +54,10 @@ VALIDATE_TOKEN_C = re.compile(r'[A-Za-z0-9]{24}') SLACK_DEFAULT_USER = 'apprise' # Extend HTTP Error Messages -SLACK_HTTP_ERROR_MAP = dict(HTTP_ERROR_MAP.items() + { +SLACK_HTTP_ERROR_MAP = HTTP_ERROR_MAP.copy() +SLACK_HTTP_ERROR_MAP.update({ 401: 'Unauthorized - Invalid Token.', -}.items()) +}) # Used to break path apart into list of devices CHANNEL_LIST_DELIM = re.compile(r'[ \t\r\n,#\\/]+') @@ -124,11 +126,11 @@ class NotifySlack(NotifyBase): 'No user was specified; using %s.' % SLACK_DEFAULT_USER) self.user = SLACK_DEFAULT_USER - if isinstance(channels, basestring): + if compat_is_basestring(channels): self.channels = filter(bool, CHANNEL_LIST_DELIM.split( channels, )) - elif isinstance(channels, (tuple, list)): + elif isinstance(channels, (set, tuple, list)): self.channels = channels else: self.channels = list() diff --git a/apprise/plugins/NotifyTelegram.py b/apprise/plugins/NotifyTelegram.py index 2dcd14b1..e68c70ab 100644 --- a/apprise/plugins/NotifyTelegram.py +++ b/apprise/plugins/NotifyTelegram.py @@ -50,6 +50,8 @@ from .NotifyBase import NotifyBase from .NotifyBase import NotifyFormat from .NotifyBase import HTTP_ERROR_MAP +from ..utils import compat_is_basestring + # Token required as part of the API request # allow the word 'bot' infront VALIDATE_BOT_TOKEN = re.compile( @@ -108,11 +110,12 @@ class NotifyTelegram(NotifyBase): # Store our API Key self.bot_token = result.group('key') - if isinstance(chat_ids, basestring): + if compat_is_basestring(chat_ids): self.chat_ids = filter(bool, CHAT_ID_LIST_DELIM.split( chat_ids, )) - elif isinstance(chat_ids, (tuple, list)): + + elif isinstance(chat_ids, (set, tuple, list)): self.chat_ids = list(chat_ids) else: @@ -246,8 +249,8 @@ class NotifyTelegram(NotifyBase): # payload['parse_mode'] = 'Markdown' payload['parse_mode'] = 'HTML' payload['text'] = '%s\r\n%s' % ( - self.escape_html(title), - self.escape_html(body), + NotifyBase.escape_html(title), + NotifyBase.escape_html(body), ) # Create a copy of the chat_ids list diff --git a/apprise/plugins/NotifyToasty.py b/apprise/plugins/NotifyToasty.py index 6f29dd97..da7b61ac 100644 --- a/apprise/plugins/NotifyToasty.py +++ b/apprise/plugins/NotifyToasty.py @@ -18,12 +18,11 @@ import re import requests -from urllib import quote -from urllib import unquote from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..utils import compat_is_basestring # Image Support (128x128) TOASTY_IMAGE_XY = NotifyImageSize.XY_128 @@ -52,7 +51,7 @@ class NotifyToasty(NotifyBase): title_maxlen=250, body_maxlen=32768, image_size=TOASTY_IMAGE_XY, **kwargs) - if isinstance(devices, basestring): + if compat_is_basestring(devices): self.devices = filter(bool, DEVICES_LIST_DELIM.split( devices, )) @@ -86,9 +85,9 @@ class NotifyToasty(NotifyBase): # prepare JSON Object payload = { - 'sender': quote(self.user), - 'title': quote(title), - 'text': quote(body), + 'sender': NotifyBase.quote(self.user), + 'title': NotifyBase.quote(title), + 'text': NotifyBase.quote(body), } if self.include_image: @@ -163,7 +162,7 @@ class NotifyToasty(NotifyBase): # Apply our settings now try: - devices = unquote(results['fullpath']) + devices = NotifyBase.unquote(results['fullpath']) except AttributeError: devices = '' diff --git a/apprise/plugins/NotifyXML.py b/apprise/plugins/NotifyXML.py index 0f98a35f..3dfac503 100644 --- a/apprise/plugins/NotifyXML.py +++ b/apprise/plugins/NotifyXML.py @@ -18,11 +18,11 @@ import re import requests -from urllib import quote from .NotifyBase import NotifyBase from .NotifyBase import HTTP_ERROR_MAP from ..common import NotifyImageSize +from ..utils import compat_is_basestring # Image Support (128x128) XML_IMAGE_XY = NotifyImageSize.XY_128 @@ -69,7 +69,7 @@ class NotifyXML(NotifyBase): self.schema = 'http' self.fullpath = kwargs.get('fullpath') - if not isinstance(self.fullpath, basestring): + if not compat_is_basestring(self.fullpath): self.fullpath = '/' return @@ -86,9 +86,9 @@ class NotifyXML(NotifyBase): } re_map = { - '{MESSAGE_TYPE}': quote(notify_type), - '{SUBJECT}': quote(title), - '{MESSAGE}': quote(body), + '{MESSAGE_TYPE}': NotifyBase.quote(notify_type), + '{SUBJECT}': NotifyBase.quote(title), + '{MESSAGE}': NotifyBase.quote(body), } # Iterate over above list and store content accordingly diff --git a/apprise/utils.py b/apprise/utils.py index 678d7162..87f10349 100644 --- a/apprise/utils.py +++ b/apprise/utils.py @@ -20,18 +20,30 @@ import re from os.path import expanduser -from urlparse import urlparse -from urlparse import parse_qsl -from urllib import quote -from urllib import unquote +try: + # Python 2.7 + from urllib import unquote + from urllib import quote + from urlparse import urlparse + from urlparse import parse_qsl + +except ImportError: + # Python 3.x + from urllib.parse import unquote + from urllib.parse import quote + from urllib.parse import urlparse + from urllib.parse import parse_qsl import logging logger = logging.getLogger(__name__) # URL Indexing Table for returns via parse_url() -VALID_URL_RE = re.compile(r'^[\s]*([^:\s]+):[/\\]*([^?]+)(\?(.+))?[\s]*$') -VALID_HOST_RE = re.compile(r'^[\s]*([^:/\s]+)') -VALID_QUERY_RE = re.compile(r'^(.*[/\\])([^/\\]*)$') +VALID_URL_RE = re.compile( + r'^[\s]*(?P[^:\s]+):[/\\]*(?P[^?]+)' + r'(\?(?P.+))?[\s]*$', +) +VALID_HOST_RE = re.compile(r'^[\s]*(?P[^?\s]+)(\?(?P.+))?') +VALID_QUERY_RE = re.compile(r'^(?P.*[/\\])(?P[^/\\]*)$') # delimiters used to separate values when content is passed in by string. # This is useful when turning a string into a list @@ -43,7 +55,7 @@ ESCAPED_WIN_PATH_SEPARATOR = re.escape('\\') ESCAPED_NUX_PATH_SEPARATOR = re.escape('/') TIDY_WIN_PATH_RE = re.compile( - '(^[%s]{2}|[^%s\s][%s]|[\s][%s]{2}])([%s]+)' % ( + r'(^[%s]{2}|[^%s\s][%s]|[\s][%s]{2}])([%s]+)' % ( ESCAPED_WIN_PATH_SEPARATOR, ESCAPED_WIN_PATH_SEPARATOR, ESCAPED_WIN_PATH_SEPARATOR, @@ -52,27 +64,41 @@ TIDY_WIN_PATH_RE = re.compile( ), ) TIDY_WIN_TRIM_RE = re.compile( - '^(.+[^:][^%s])[\s%s]*$' % ( + r'^(.+[^:][^%s])[\s%s]*$' % ( ESCAPED_WIN_PATH_SEPARATOR, ESCAPED_WIN_PATH_SEPARATOR, ), ) TIDY_NUX_PATH_RE = re.compile( - '([%s])([%s]+)' % ( + r'([%s])([%s]+)' % ( ESCAPED_NUX_PATH_SEPARATOR, ESCAPED_NUX_PATH_SEPARATOR, ), ) TIDY_NUX_TRIM_RE = re.compile( - '([^%s])[\s%s]+$' % ( + r'([^%s])[\s%s]+$' % ( ESCAPED_NUX_PATH_SEPARATOR, ESCAPED_NUX_PATH_SEPARATOR, ), ) +def compat_is_basestring(content): + """ + Python 3 support for checking if content is unicode and/or + of a string type + """ + try: + # Python v2.x + return isinstance(content, basestring) + + except NameError: + # Python v3.x + return isinstance(content, str) + + def tidy_path(path): """take a filename and or directory and attempts to tidy it up by removing trailing slashes and correcting any formatting issues. @@ -116,7 +142,7 @@ def parse_url(url, default_schema='http'): content could not be extracted. """ - if not isinstance(url, basestring): + if not compat_is_basestring(url): # Simple error checking return None @@ -150,32 +176,36 @@ def parse_url(url, default_schema='http'): match = VALID_URL_RE.search(url) if match: # Extract basic results - result['schema'] = match.group(1).lower().strip() - host = match.group(2).strip() + result['schema'] = match.group('schema').lower().strip() + host = match.group('path').strip() try: - qsdata = match.group(4).strip() + qsdata = match.group('kwargs').strip() except AttributeError: # No qsdata pass + else: match = VALID_HOST_RE.search(url) if not match: return None result['schema'] = default_schema - host = match.group(1).strip() - - if not result['schema']: - result['schema'] = default_schema - - if not host: - # Invalid Hostname - return None + host = match.group('path').strip() + try: + qsdata = match.group('kwargs').strip() + except AttributeError: + # No qsdata + pass # Now do a proper extraction of data parsed = urlparse('http://%s' % host) # Parse results result['host'] = parsed[1].strip() + + if not result['host']: + # Nothing more we can do without a hostname + return None + result['fullpath'] = quote(unquote(tidy_path(parsed[2].strip()))) try: # Handle trailing slashes removed by tidy_path @@ -201,14 +231,13 @@ def parse_url(url, default_schema='http'): if not result['fullpath']: # Default result['fullpath'] = None + else: # Using full path, extract query from path match = VALID_QUERY_RE.search(result['fullpath']) if match: - result['path'] = match.group(1) - result['query'] = match.group(2) - if not result['path']: - result['path'] = None + result['path'] = match.group('path') + result['query'] = match.group('query') if not result['query']: result['query'] = None try: @@ -242,18 +271,22 @@ def parse_url(url, default_schema='http'): if result['port']: try: result['port'] = int(result['port']) + except (ValueError, TypeError): # Invalid Port Specified return None + if result['port'] == 0: result['port'] = None # Re-assemble cleaned up version of the url result['url'] = '%s://' % result['schema'] - if isinstance(result['user'], basestring): + if compat_is_basestring(result['user']): result['url'] += result['user'] - if isinstance(result['password'], basestring): + + if compat_is_basestring(result['password']): result['url'] += ':%s@' % result['password'] + else: result['url'] += '@' result['url'] += result['host'] @@ -277,7 +310,7 @@ def parse_bool(arg, default=False): If the content could not be parsed, then the default is returned. """ - if isinstance(arg, basestring): + if compat_is_basestring(arg): # no = no - False # of = short for off - False # 0 = int for False @@ -330,23 +363,20 @@ def parse_list(*args): result = [] for arg in args: - if isinstance(arg, basestring): + if compat_is_basestring(arg): result += re.split(STRING_DELIMITERS, arg) - elif isinstance(arg, (list, tuple)): - for _arg in arg: - if isinstance(arg, basestring): - result += re.split(STRING_DELIMITERS, arg) - # A list inside a list? - use recursion - elif isinstance(_arg, (list, tuple)): - result += parse_list(_arg) - else: - # Convert whatever it is to a string and work with it - result += parse_list(str(_arg)) + elif isinstance(arg, (set, list, tuple)): + result += parse_list(*arg) + else: # Convert whatever it is to a string and work with it result += parse_list(str(arg)) - # apply as well as make the list unique by converting it - # to a set() first. filter() eliminates any empty entries - return filter(bool, list(set(result))) + # + # filter() eliminates any empty entries + # + # Since Python v3 returns a filter (iterator) where-as Python v2 returned + # a list, we need to change it into a list object to remain compatible with + # both distribution types. + return sorted([x for x in filter(bool, list(set(result)))]) diff --git a/dev-requirements.txt b/dev-requirements.txt new file mode 100644 index 00000000..483bf77a --- /dev/null +++ b/dev-requirements.txt @@ -0,0 +1,5 @@ +pytest +coverage +pytest-cov +pycodestyle +tox diff --git a/requirements.txt b/requirements.txt index b65f9969..8fca4c2f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,3 @@ -chardet -markdown decorator requests requests-oauthlib diff --git a/setup.cfg b/setup.cfg index e11988a0..8973070a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,14 +1,22 @@ -[egg_info] -tag_build = -tag_date = 0 -tag_svn_revision = 0 +[bdist_wheel] +universal = 1 + +[metadata] +# ensure LICENSE is included in wheel metadata +license_file = LICENSE [pycodestyle] # We exclude packages we don't maintain -exclude = gntp,tweepy,pushjet +exclude = .eggs,.tox,gntp,tweepy,pushjet ignore = E722 -statistics = True +statistics = true -[coverage:run] -source=apprise -omit=*/gntp/*,*/tweepy/*,*/pushjet/* +[aliases] +test=pytest + +[tool:pytest] +addopts = --verbose -ra +python_files = test/test_*.py +filterwarnings = + once::Warning +strict = true diff --git a/setup.py b/setup.py index 5bd8b7d3..f1250cad 100755 --- a/setup.py +++ b/setup.py @@ -45,9 +45,11 @@ setup( author='Chris Caron', author_email='lead2gold@gmail.com', packages=find_packages(), - include_package_data=True, package_data={ - 'apprise': ['assets'], + 'apprise': [ + 'assets/NotifyXML-1.0.xsd', + 'assets/themes/default/*.png', + ], }, scripts=['cli/notify.py', ], install_requires=open('requirements.txt').readlines(), @@ -58,10 +60,11 @@ setup( 'Natural Language :: English', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', + 'Programming Language :: Python :: 3', 'License :: OSI Approved :: GNU General Public License v3 (GPLv3)', ), entry_points={'console_scripts': console_scripts}, - python_requires='>=2.7, <3', - test_suite='nose.collector', - tests_require=['nose', 'coverage', 'pycodestyle'], + python_requires='>=2.7', + setup_requires=['pytest-runner', ], + tests_require=['pytest', 'coverage', 'pytest-cov', 'pycodestyle', 'tox'], ) diff --git a/test/test_api.py b/test/test_api.py index 2b618840..0425a504 100644 --- a/test/test_api.py +++ b/test/test_api.py @@ -1,9 +1,25 @@ -"""API properties. - -""" +# -*- coding: utf-8 -*- +# +# Apprise and AppriseAsset Unit Tests +# +# Copyright (C) 2017 Chris Caron +# +# This file is part of apprise. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. from __future__ import print_function from __future__ import unicode_literals +from os import chmod +from os.path import dirname from apprise import Apprise from apprise import AppriseAsset from apprise.Apprise import SCHEMA_MAP @@ -150,7 +166,7 @@ def test_apprise(): assert(a.notify(title="present", body="present") is False) -def test_apprise_asset(): +def test_apprise_asset(tmpdir): """ API: AppriseAsset() object @@ -175,6 +191,10 @@ def test_apprise_asset(): NotifyImageSize.XY_256, must_exist=False) == '/dark/info-256x256.png') + # This path doesn't exist so image_raw will fail (since we just + # randompyl picked it for testing) + assert(a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None) + assert(a.image_path( NotifyType.INFO, NotifyImageSize.XY_256, @@ -190,3 +210,66 @@ def test_apprise_asset(): must_exist=True) is not None) assert(a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None) + + # Create a temporary directory + sub = tmpdir.mkdir("great.theme") + + # Write a file + sub.join("{0}-{1}.png".format( + NotifyType.INFO, + NotifyImageSize.XY_256, + )).write("the content doesn't matter for testing.") + + # Create an asset that will reference our file we just created + a = AppriseAsset( + theme='great.theme', + image_path_mask='%s/{THEME}/{TYPE}-{XY}.png' % dirname(sub.strpath), + ) + + # We'll be able to read file we just created + assert(a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None) + + # We can retrieve the filename at this point even with must_exist set + # to True + assert(a.image_path( + NotifyType.INFO, + NotifyImageSize.XY_256, + must_exist=True) is not None) + + # If we make the file un-readable however, we won't be able to read it + # This test is just showing that we won't throw an exception + chmod(dirname(sub.strpath), 0o000) + assert(a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None) + + # Our path doesn't exist anymore using this logic + assert(a.image_path( + NotifyType.INFO, + NotifyImageSize.XY_256, + must_exist=True) is None) + + # Return our permission so we don't have any problems with our cleanup + chmod(dirname(sub.strpath), 0o700) + + # Our content is retrivable again + assert(a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is not None) + + # our file path is accessible again too + assert(a.image_path( + NotifyType.INFO, + NotifyImageSize.XY_256, + must_exist=True) is not None) + + # We do the same test, but set the permission on the file + chmod(a.image_path(NotifyType.INFO, NotifyImageSize.XY_256), 0o000) + + # our path will still exist in this case + assert(a.image_path( + NotifyType.INFO, + NotifyImageSize.XY_256, + must_exist=True) is not None) + + # but we will not be able to open it + assert(a.image_raw(NotifyType.INFO, NotifyImageSize.XY_256) is None) + + # Restore our permissions + chmod(a.image_path(NotifyType.INFO, NotifyImageSize.XY_256), 0o640) diff --git a/test/test_notifybase.py b/test/test_notifybase.py new file mode 100644 index 00000000..1c836b9e --- /dev/null +++ b/test/test_notifybase.py @@ -0,0 +1,158 @@ +# -*- coding: utf-8 -*- +# +# NotifyBase Unit Tests +# +# Copyright (C) 2017 Chris Caron +# +# This file is part of apprise. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. + +from apprise.plugins.NotifyBase import NotifyBase +from apprise import NotifyType +from apprise import NotifyImageSize +from timeit import default_timer + + +def test_notify_base(): + """ + API: NotifyBase() object + + """ + + # invalid types throw exceptions + try: + nb = NotifyBase(notify_format='invalid') + # We should never reach here as an exception should be thrown + assert(False) + + except TypeError: + assert(True) + + try: + nb = NotifyBase(image_size='invalid') + # We should never reach here as an exception should be thrown + assert(False) + + except TypeError: + assert(True) + + # Bad port information + nb = NotifyBase(port='invalid') + assert nb.port is None + + nb = NotifyBase(port=10) + assert nb.port == 10 + + # Throttle overrides.. + nb = NotifyBase(throttle=0) + start_time = default_timer() + nb.throttle() + elapsed = default_timer() - start_time + # Should be a very fast response time since we set it to zero but we'll + # check for less then 500 to be fair as some testing systems may be slower + # then other + assert elapsed < 0.5 + + start_time = default_timer() + nb.throttle(1.0) + elapsed = default_timer() - start_time + # Should be a very fast response time since we set it to zero but we'll + # check for less then 500 to be fair as some testing systems may be slower + # then other + assert elapsed < 1.5 + + # our NotifyBase wasn't initialized with an ImageSize so this will fail + assert nb.image_url(notify_type=NotifyType.INFO) is None + assert nb.image_path(notify_type=NotifyType.INFO) is None + assert nb.image_raw(notify_type=NotifyType.INFO) is None + + # Create an object with an ImageSize loaded into it + nb = NotifyBase(image_size=NotifyImageSize.XY_256) + + # We'll get an object thi time around + assert nb.image_url(notify_type=NotifyType.INFO) is not None + assert nb.image_path(notify_type=NotifyType.INFO) is not None + assert nb.image_raw(notify_type=NotifyType.INFO) is not None + + # But we will not get a response with an invalid notification type + assert nb.image_url(notify_type='invalid') is None + assert nb.image_path(notify_type='invalid') is None + assert nb.image_raw(notify_type='invalid') is None + + # Static function testing + assert NotifyBase.escape_html("'\t \n") == \ + '<content>'  \n</content>' + + assert NotifyBase.escape_html( + "'\t \n", convert_new_lines=True) == \ + '<content>'  <br/></content>' + + assert NotifyBase.split_path( + '/path/?name=Dr%20Disrespect', unquote=False) == \ + ['path', '?name=Dr%20Disrespect'] + + assert NotifyBase.split_path( + '/path/?name=Dr%20Disrespect', unquote=True) == \ + ['path', '?name=Dr', 'Disrespect'] + + assert NotifyBase.is_email('test@gmail.com') is True + assert NotifyBase.is_email('invalid.com') is False + + +def test_notify_base_urls(): + """ + API: NotifyBase() URLs + + """ + + # Test verify switch whih is used as part of the SSL Verification + # by default all SSL sites are verified unless this flag is set to + # something like 'No', 'False', 'Disabled', etc. Boolean values are + # pretty forgiving. + results = NotifyBase.parse_url('https://localhost:8080/?verify=No') + assert 'verify' in results + assert results['verify'] is False + + results = NotifyBase.parse_url('https://localhost:8080/?verify=Yes') + assert 'verify' in results + assert results['verify'] is True + + # The default is to verify + results = NotifyBase.parse_url('https://localhost:8080') + assert 'verify' in results + assert results['verify'] is True + + # Password Handling + + # pass keyword over-rides default password + results = NotifyBase.parse_url('https://user:pass@localhost') + assert 'password' in results + assert results['password'] == "pass" + + # pass keyword over-rides default password + results = NotifyBase.parse_url( + 'https://user:pass@localhost?pass=newpassword') + assert 'password' in results + assert results['password'] == "newpassword" + + # User Handling + + # user keyword over-rides default password + results = NotifyBase.parse_url('https://user:pass@localhost') + assert 'user' in results + assert results['user'] == "user" + + # user keyword over-rides default password + results = NotifyBase.parse_url( + 'https://user:pass@localhost?user=newuser') + assert 'user' in results + assert results['user'] == "newuser" diff --git a/test/test_utils.py b/test/test_utils.py index b10af292..8d14dd8f 100644 --- a/test/test_utils.py +++ b/test/test_utils.py @@ -1,10 +1,31 @@ -"""API properties. - -""" +# -*- coding: utf-8 -*- +# +# Unit Tests for common shared utility functions +# +# Copyright (C) 2017 Chris Caron +# +# This file is part of apprise. +# +# This program is free software; you can redistribute it and/or modify it +# under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation; either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. from __future__ import print_function from __future__ import unicode_literals -from urllib import unquote +try: + # Python 2.7 + from urllib import unquote + +except ImportError: + # Python 3.x + from urllib.parse import unquote + from apprise import utils @@ -153,6 +174,54 @@ def test_parse_url(): ) assert(result is None) + # just hostnames + result = utils.parse_url( + 'nuxref.com' + ) + assert(result['schema'] == 'http') + assert(result['host'] == 'nuxref.com') + assert(result['port'] is None) + assert(result['user'] is None) + assert(result['password'] is None) + assert(result['fullpath'] is None) + assert(result['path'] is None) + assert(result['query'] is None) + assert(result['url'] == 'http://nuxref.com') + assert(result['qsd'] == {}) + + # just host and path + result = utils.parse_url( + 'invalid/host' + ) + assert(result['schema'] == 'http') + assert(result['host'] == 'invalid') + assert(result['port'] is None) + assert(result['user'] is None) + assert(result['password'] is None) + assert(result['fullpath'] == '/host') + assert(result['path'] == '/') + assert(result['query'] == 'host') + assert(result['url'] == 'http://invalid/host') + assert(result['qsd'] == {}) + + # just all out invalid + assert(utils.parse_url('?') is None) + assert(utils.parse_url('/') is None) + + # A default port of zero is still considered valid, but + # is removed in the response. + result = utils.parse_url('http://nuxref.com:0') + assert(result['schema'] == 'http') + assert(result['host'] == 'nuxref.com') + assert(result['port'] is None) + assert(result['user'] is None) + assert(result['password'] is None) + assert(result['fullpath'] is None) + assert(result['path'] is None) + assert(result['query'] is None) + assert(result['url'] == 'http://nuxref.com') + assert(result['qsd'] == {}) + def test_parse_bool(): "utils: parse_bool() testing """ @@ -202,21 +271,25 @@ def test_parse_list(): results = utils.parse_list( '.mkv,.avi,.divx,.xvid,.mov,.wmv,.mp4,.mpg,.mpeg,.vob,.iso') - assert(results == [ + assert(results == sorted([ '.divx', '.iso', '.mkv', '.mov', '.mpg', '.avi', '.mpeg', '.vob', '.xvid', '.wmv', '.mp4', - ]) + ])) + class StrangeObject(object): + def __str__(self): + return '.avi' # Now 2 lists with lots of duplicates and other delimiters results = utils.parse_list( '.mkv,.avi,.divx,.xvid,.mov,.wmv,.mp4,.mpg .mpeg,.vob,,; ;', - '.mkv,.avi,.divx,.xvid,.mov .wmv,.mp4;.mpg,.mpeg,' - '.vob,.iso') + ('.mkv,.avi,.divx,.xvid,.mov ', ' .wmv,.mp4;.mpg,.mpeg,'), + '.vob,.iso', ['.vob', ['.vob', '.mkv', StrangeObject(), ], ], + StrangeObject()) - assert(results == [ + assert(results == sorted([ '.divx', '.iso', '.mkv', '.mov', '.mpg', '.avi', '.mpeg', '.vob', '.xvid', '.wmv', '.mp4', - ]) + ])) # Now a list with extras we want to add as strings # empty entries are removed @@ -224,7 +297,7 @@ def test_parse_list(): '.divx', '.iso', '.mkv', '.mov', '', ' ', '.avi', '.mpeg', '.vob', '.xvid', '.mp4'], '.mov,.wmv,.mp4,.mpg') - assert(results == [ + assert(results == sorted([ '.divx', '.wmv', '.iso', '.mkv', '.mov', '.mpg', '.avi', '.vob', '.xvid', '.mpeg', '.mp4', - ]) + ])) diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..efc45ba8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,52 @@ +[tox] +envlist = py27,py34,py35,py36,pypy,pypy3,coverage-report + + +[testenv] +# Prevent random setuptools/pip breakages like +# https://github.com/pypa/setuptools/issues/1042 from breaking our builds. +setenv = + VIRTUALENV_NO_DOWNLOAD=1 +deps= + -r{toxinidir}/requirements.txt + -r{toxinidir}/dev-requirements.txt +commands = python -m pytest {posargs} + + +[testenv:py27] +deps= + -r{toxinidir}/requirements.txt + -r{toxinidir}/dev-requirements.txt +commands = coverage run --parallel -m pytest {posargs} + +[testenv:py34] +deps= + -r{toxinidir}/requirements.txt + -r{toxinidir}/dev-requirements.txt +commands = coverage run --parallel -m pytest {posargs} + +[testenv:py35] +deps= + -r{toxinidir}/requirements.txt + -r{toxinidir}/dev-requirements.txt +commands = coverage run --parallel -m pytest {posargs} + +[testenv:py36] +deps= + -r{toxinidir}/requirements.txt + -r{toxinidir}/dev-requirements.txt +commands = coverage run --parallel -m pytest {posargs} + +[testenv:pypy] +deps= + -r{toxinidir}/requirements.txt + -r{toxinidir}/dev-requirements.txt +commands = coverage run --parallel -m pytest {posargs} + + +[testenv:coverage-report] +deps = coverage +skip_install = true +commands= + coverage combine + coverage report