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):
- """
- Attempts to convert non-utf8 content to... (you guessed it) utf8
+ @staticmethod
+ def unquote(content, encoding='utf-8', errors='replace'):
"""
- if not content:
- return ''
+ common unquote function
- 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))
+ @staticmethod
+ def quote(content, safe='/', encoding=None, errors=None):
+ """
+ common quote function
- except KeyError:
- raise ValueError(
- '%s encoding could not be detected ' % (
- content))
+ """
+ try:
+ # Python v3.x
+ return _quote(content, safe=safe, encoding=encoding, errors=errors)
- return ''
+ except TypeError:
+ # Python v2.7
+ return _quote(content, safe=safe)
- def to_html(self, body):
- """
- Returns the specified title in an html format and factors
- in a titles defined max length
+ @staticmethod
+ def urlencode(query, doseq=False, safe='', encoding=None, errors=None):
"""
- html = markdown.markdown(body)
+ common urlencode function
- # TODO:
- # This function should return multiple messages if we exceed
- # the maximum number of characters. the second message should
+ """
+ try:
+ # Python v3.x
+ return _urlencode(
+ query, doseq=doseq, safe=safe, encoding=encoding,
+ errors=errors)
- # 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, ]
+ 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
+
+[aliases]
+test=pytest
-[coverage:run]
-source=apprise
-omit=*/gntp/*,*/tweepy/*,*/pushjet/*
+[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