mirror of https://github.com/caronc/apprise
Growl notification library rewrite; gntp now external dependency (#252)
parent
f3993b18ae
commit
479218da6a
|
@ -1,6 +1,6 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
#
|
#
|
||||||
# Copyright (C) 2019 Chris Caron <lead2gold@gmail.com>
|
# Copyright (C) 2020 Chris Caron <lead2gold@gmail.com>
|
||||||
# All rights reserved.
|
# All rights reserved.
|
||||||
#
|
#
|
||||||
# This code is licensed under the MIT License.
|
# This code is licensed under the MIT License.
|
||||||
|
@ -23,14 +23,26 @@
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
|
||||||
from .gntp import notifier
|
from .NotifyBase import NotifyBase
|
||||||
from .gntp import errors
|
from ..URLBase import PrivacyMode
|
||||||
from ..NotifyBase import NotifyBase
|
from ..common import NotifyImageSize
|
||||||
from ...URLBase import PrivacyMode
|
from ..common import NotifyType
|
||||||
from ...common import NotifyImageSize
|
from ..utils import parse_bool
|
||||||
from ...common import NotifyType
|
from ..AppriseLocale import gettext_lazy as _
|
||||||
from ...utils import parse_bool
|
|
||||||
from ...AppriseLocale import gettext_lazy as _
|
# Default our global support flag
|
||||||
|
NOTIFY_GROWL_SUPPORT_ENABLED = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
import gntp.notifier
|
||||||
|
|
||||||
|
# We're good to go!
|
||||||
|
NOTIFY_GROWL_SUPPORT_ENABLED = True
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
# No problem; we just simply can't support this plugin until
|
||||||
|
# gntp is installed
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
# Priorities
|
# Priorities
|
||||||
|
@ -50,8 +62,6 @@ GROWL_PRIORITIES = (
|
||||||
GrowlPriority.EMERGENCY,
|
GrowlPriority.EMERGENCY,
|
||||||
)
|
)
|
||||||
|
|
||||||
GROWL_NOTIFICATION_TYPE = "New Messages"
|
|
||||||
|
|
||||||
|
|
||||||
class NotifyGrowl(NotifyBase):
|
class NotifyGrowl(NotifyBase):
|
||||||
"""
|
"""
|
||||||
|
@ -74,14 +84,19 @@ class NotifyGrowl(NotifyBase):
|
||||||
# Allows the user to specify the NotifyImageSize object
|
# Allows the user to specify the NotifyImageSize object
|
||||||
image_size = NotifyImageSize.XY_72
|
image_size = NotifyImageSize.XY_72
|
||||||
|
|
||||||
|
# This entry is a bit hacky, but it allows us to unit-test this library
|
||||||
|
# in an environment that simply doesn't have the windows packages
|
||||||
|
# available to us. It also allows us to handle situations where the
|
||||||
|
# packages actually are present but we need to test that they aren't.
|
||||||
|
# If anyone is seeing this had knows a better way of testing this
|
||||||
|
# outside of what is defined in test/test_growl_plugin.py, please
|
||||||
|
# let me know! :)
|
||||||
|
_enabled = NOTIFY_GROWL_SUPPORT_ENABLED
|
||||||
|
|
||||||
# Disable throttle rate for Growl requests since they are normally
|
# Disable throttle rate for Growl requests since they are normally
|
||||||
# local anyway
|
# local anyway
|
||||||
request_rate_per_sec = 0
|
request_rate_per_sec = 0
|
||||||
|
|
||||||
# A title can not be used for Growl Messages. Setting this to zero will
|
|
||||||
# cause any title (if defined) to get placed into the message body.
|
|
||||||
title_maxlen = 0
|
|
||||||
|
|
||||||
# Limit results to just the first 10 line otherwise there is just to much
|
# Limit results to just the first 10 line otherwise there is just to much
|
||||||
# content to display
|
# content to display
|
||||||
body_max_line_count = 2
|
body_max_line_count = 2
|
||||||
|
@ -89,7 +104,9 @@ class NotifyGrowl(NotifyBase):
|
||||||
# Default Growl Port
|
# Default Growl Port
|
||||||
default_port = 23053
|
default_port = 23053
|
||||||
|
|
||||||
# Define object templates
|
# The Growl notification type used
|
||||||
|
growl_notification_type = "New Messages"
|
||||||
|
|
||||||
# Define object templates
|
# Define object templates
|
||||||
templates = (
|
templates = (
|
||||||
'{schema}://{host}',
|
'{schema}://{host}',
|
||||||
|
@ -138,9 +155,16 @@ class NotifyGrowl(NotifyBase):
|
||||||
'default': True,
|
'default': True,
|
||||||
'map_to': 'include_image',
|
'map_to': 'include_image',
|
||||||
},
|
},
|
||||||
|
'sticky': {
|
||||||
|
'name': _('Sticky'),
|
||||||
|
'type': 'bool',
|
||||||
|
'default': True,
|
||||||
|
'map_to': 'sticky',
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
def __init__(self, priority=None, version=2, include_image=True, **kwargs):
|
def __init__(self, priority=None, version=2, include_image=True,
|
||||||
|
sticky=False, **kwargs):
|
||||||
"""
|
"""
|
||||||
Initialize Growl Object
|
Initialize Growl Object
|
||||||
"""
|
"""
|
||||||
|
@ -156,16 +180,29 @@ class NotifyGrowl(NotifyBase):
|
||||||
else:
|
else:
|
||||||
self.priority = priority
|
self.priority = priority
|
||||||
|
|
||||||
# Always default the sticky flag to False
|
# Our Registered object
|
||||||
self.sticky = False
|
self.growl = None
|
||||||
|
|
||||||
|
# Sticky flag
|
||||||
|
self.sticky = sticky
|
||||||
|
|
||||||
# Store Version
|
# Store Version
|
||||||
self.version = version
|
self.version = version
|
||||||
|
|
||||||
|
# Track whether or not we want to send an image with our notification
|
||||||
|
# or not.
|
||||||
|
self.include_image = include_image
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
def register(self):
|
||||||
|
"""
|
||||||
|
Registers with the Growl server
|
||||||
|
"""
|
||||||
payload = {
|
payload = {
|
||||||
'applicationName': self.app_id,
|
'applicationName': self.app_id,
|
||||||
'notifications': [GROWL_NOTIFICATION_TYPE, ],
|
'notifications': [self.growl_notification_type, ],
|
||||||
'defaultNotifications': [GROWL_NOTIFICATION_TYPE, ],
|
'defaultNotifications': [self.growl_notification_type, ],
|
||||||
'hostname': self.host,
|
'hostname': self.host,
|
||||||
'port': self.port,
|
'port': self.port,
|
||||||
}
|
}
|
||||||
|
@ -174,43 +211,58 @@ class NotifyGrowl(NotifyBase):
|
||||||
payload['password'] = self.password
|
payload['password'] = self.password
|
||||||
|
|
||||||
self.logger.debug('Growl Registration Payload: %s' % str(payload))
|
self.logger.debug('Growl Registration Payload: %s' % str(payload))
|
||||||
self.growl = notifier.GrowlNotifier(**payload)
|
self.growl = gntp.notifier.GrowlNotifier(**payload)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.growl.register()
|
self.growl.register()
|
||||||
self.logger.debug(
|
|
||||||
'Growl server registration completed successfully.'
|
|
||||||
)
|
|
||||||
|
|
||||||
except errors.NetworkError:
|
except gntp.errors.NetworkError:
|
||||||
msg = 'A network error occurred sending Growl ' \
|
msg = 'A network error error occurred registering ' \
|
||||||
'notification to {}.'.format(self.host)
|
'with Growl at {}.'.format(self.host)
|
||||||
self.logger.warning(msg)
|
self.logger.warning(msg)
|
||||||
raise TypeError(msg)
|
return False
|
||||||
|
|
||||||
except errors.AuthError:
|
except gntp.errors.ParseError:
|
||||||
msg = 'An authentication error occurred sending Growl ' \
|
msg = 'A parsing error error occurred registering ' \
|
||||||
'notification to {}.'.format(self.host)
|
'with Growl at {}.'.format(self.host)
|
||||||
self.logger.warning(msg)
|
self.logger.warning(msg)
|
||||||
raise TypeError(msg)
|
return False
|
||||||
|
|
||||||
except errors.UnsupportedError:
|
except gntp.errors.AuthError:
|
||||||
msg = 'An unsupported error occurred sending Growl ' \
|
msg = 'An authentication error error occurred registering ' \
|
||||||
'notification to {}.'.format(self.host)
|
'with Growl at {}.'.format(self.host)
|
||||||
self.logger.warning(msg)
|
self.logger.warning(msg)
|
||||||
raise TypeError(msg)
|
return False
|
||||||
|
|
||||||
# Track whether or not we want to send an image with our notification
|
except gntp.errors.UnsupportedError:
|
||||||
# or not.
|
msg = 'An unsupported error occurred registering with ' \
|
||||||
self.include_image = include_image
|
'Growl at {}.'.format(self.host)
|
||||||
|
self.logger.warning(msg)
|
||||||
|
return False
|
||||||
|
|
||||||
return
|
self.logger.debug(
|
||||||
|
'Growl server registration completed successfully.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return our state
|
||||||
|
return True
|
||||||
|
|
||||||
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
def send(self, body, title='', notify_type=NotifyType.INFO, **kwargs):
|
||||||
"""
|
"""
|
||||||
Perform Growl Notification
|
Perform Growl Notification
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
if not self._enabled:
|
||||||
|
self.logger.warning(
|
||||||
|
"Growl Notifications are not supported by this system; "
|
||||||
|
"`pip install gntp`.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Register ourselves with the server if we haven't done so already
|
||||||
|
if not self.growl and not self.register():
|
||||||
|
# We failed to register
|
||||||
|
return False
|
||||||
|
|
||||||
icon = None
|
icon = None
|
||||||
if self.version >= 2:
|
if self.version >= 2:
|
||||||
# URL Based
|
# URL Based
|
||||||
|
@ -223,11 +275,11 @@ class NotifyGrowl(NotifyBase):
|
||||||
else self.image_raw(notify_type)
|
else self.image_raw(notify_type)
|
||||||
|
|
||||||
payload = {
|
payload = {
|
||||||
'noteType': GROWL_NOTIFICATION_TYPE,
|
'noteType': self.growl_notification_type,
|
||||||
'title': title,
|
'title': title,
|
||||||
'description': body,
|
'description': body,
|
||||||
'icon': icon is not None,
|
'icon': icon is not None,
|
||||||
'sticky': False,
|
'sticky': self.sticky,
|
||||||
'priority': self.priority,
|
'priority': self.priority,
|
||||||
}
|
}
|
||||||
self.logger.debug('Growl Payload: %s' % str(payload))
|
self.logger.debug('Growl Payload: %s' % str(payload))
|
||||||
|
@ -241,6 +293,7 @@ class NotifyGrowl(NotifyBase):
|
||||||
self.throttle()
|
self.throttle()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
# Perform notification
|
||||||
response = self.growl.notify(**payload)
|
response = self.growl.notify(**payload)
|
||||||
if not isinstance(response, bool):
|
if not isinstance(response, bool):
|
||||||
self.logger.warning(
|
self.logger.warning(
|
||||||
|
@ -251,7 +304,7 @@ class NotifyGrowl(NotifyBase):
|
||||||
else:
|
else:
|
||||||
self.logger.info('Sent Growl notification.')
|
self.logger.info('Sent Growl notification.')
|
||||||
|
|
||||||
except errors.BaseError as e:
|
except gntp.errors.BaseError as e:
|
||||||
# Since Growl servers listen for UDP broadcasts, it's possible
|
# Since Growl servers listen for UDP broadcasts, it's possible
|
||||||
# that you will never get to this part of the code since there is
|
# that you will never get to this part of the code since there is
|
||||||
# no acknowledgement as to whether it accepted what was sent to it
|
# no acknowledgement as to whether it accepted what was sent to it
|
||||||
|
@ -285,6 +338,7 @@ class NotifyGrowl(NotifyBase):
|
||||||
# Define any URL parameters
|
# Define any URL parameters
|
||||||
params = {
|
params = {
|
||||||
'image': 'yes' if self.include_image else 'no',
|
'image': 'yes' if self.include_image else 'no',
|
||||||
|
'sticky': 'yes' if self.sticky else 'no',
|
||||||
'priority':
|
'priority':
|
||||||
_map[GrowlPriority.NORMAL] if self.priority not in _map
|
_map[GrowlPriority.NORMAL] if self.priority not in _map
|
||||||
else _map[self.priority],
|
else _map[self.priority],
|
||||||
|
@ -365,7 +419,13 @@ class NotifyGrowl(NotifyBase):
|
||||||
|
|
||||||
# Include images with our message
|
# Include images with our message
|
||||||
results['include_image'] = \
|
results['include_image'] = \
|
||||||
parse_bool(results['qsd'].get('image', True))
|
parse_bool(results['qsd'].get('image',
|
||||||
|
NotifyGrowl.template_args['image']['default']))
|
||||||
|
|
||||||
|
# Include images with our message
|
||||||
|
results['sticky'] = \
|
||||||
|
parse_bool(results['qsd'].get('sticky',
|
||||||
|
NotifyGrowl.template_args['sticky']['default']))
|
||||||
|
|
||||||
# Set our version
|
# Set our version
|
||||||
if version:
|
if version:
|
|
@ -1,141 +0,0 @@
|
||||||
# Copyright: 2013 Paul Traylor
|
|
||||||
# These sources are released under the terms of the MIT license: see LICENSE
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
from optparse import OptionParser, OptionGroup
|
|
||||||
|
|
||||||
from .notifier import GrowlNotifier
|
|
||||||
from .shim import RawConfigParser
|
|
||||||
from .version import __version__
|
|
||||||
|
|
||||||
DEFAULT_CONFIG = os.path.expanduser('~/.gntp')
|
|
||||||
|
|
||||||
config = RawConfigParser({
|
|
||||||
'hostname': 'localhost',
|
|
||||||
'password': None,
|
|
||||||
'port': 23053,
|
|
||||||
})
|
|
||||||
config.read([DEFAULT_CONFIG])
|
|
||||||
if not config.has_section('gntp'):
|
|
||||||
config.add_section('gntp')
|
|
||||||
|
|
||||||
|
|
||||||
class ClientParser(OptionParser):
|
|
||||||
def __init__(self):
|
|
||||||
OptionParser.__init__(self, version="%%prog %s" % __version__)
|
|
||||||
|
|
||||||
group = OptionGroup(self, "Network Options")
|
|
||||||
group.add_option("-H", "--host",
|
|
||||||
dest="host", default=config.get('gntp', 'hostname'),
|
|
||||||
help="Specify a hostname to which to send a remote notification. [%default]")
|
|
||||||
group.add_option("--port",
|
|
||||||
dest="port", default=config.getint('gntp', 'port'), type="int",
|
|
||||||
help="port to listen on [%default]")
|
|
||||||
group.add_option("-P", "--password",
|
|
||||||
dest='password', default=config.get('gntp', 'password'),
|
|
||||||
help="Network password")
|
|
||||||
self.add_option_group(group)
|
|
||||||
|
|
||||||
group = OptionGroup(self, "Notification Options")
|
|
||||||
group.add_option("-n", "--name",
|
|
||||||
dest="app", default='Python GNTP Test Client',
|
|
||||||
help="Set the name of the application [%default]")
|
|
||||||
group.add_option("-s", "--sticky",
|
|
||||||
dest='sticky', default=False, action="store_true",
|
|
||||||
help="Make the notification sticky [%default]")
|
|
||||||
group.add_option("--image",
|
|
||||||
dest="icon", default=None,
|
|
||||||
help="Icon for notification (URL or /path/to/file)")
|
|
||||||
group.add_option("-m", "--message",
|
|
||||||
dest="message", default=None,
|
|
||||||
help="Sets the message instead of using stdin")
|
|
||||||
group.add_option("-p", "--priority",
|
|
||||||
dest="priority", default=0, type="int",
|
|
||||||
help="-2 to 2 [%default]")
|
|
||||||
group.add_option("-d", "--identifier",
|
|
||||||
dest="identifier",
|
|
||||||
help="Identifier for coalescing")
|
|
||||||
group.add_option("-t", "--title",
|
|
||||||
dest="title", default=None,
|
|
||||||
help="Set the title of the notification [%default]")
|
|
||||||
group.add_option("-N", "--notification",
|
|
||||||
dest="name", default='Notification',
|
|
||||||
help="Set the notification name [%default]")
|
|
||||||
group.add_option("--callback",
|
|
||||||
dest="callback",
|
|
||||||
help="URL callback")
|
|
||||||
self.add_option_group(group)
|
|
||||||
|
|
||||||
# Extra Options
|
|
||||||
self.add_option('-v', '--verbose',
|
|
||||||
dest='verbose', default=0, action='count',
|
|
||||||
help="Verbosity levels")
|
|
||||||
|
|
||||||
def parse_args(self, args=None, values=None):
|
|
||||||
values, args = OptionParser.parse_args(self, args, values)
|
|
||||||
|
|
||||||
if values.message is None:
|
|
||||||
print('Enter a message followed by Ctrl-D')
|
|
||||||
try:
|
|
||||||
message = sys.stdin.read()
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
exit()
|
|
||||||
else:
|
|
||||||
message = values.message
|
|
||||||
|
|
||||||
if values.title is None:
|
|
||||||
values.title = ' '.join(args)
|
|
||||||
|
|
||||||
# If we still have an empty title, use the
|
|
||||||
# first bit of the message as the title
|
|
||||||
if values.title == '':
|
|
||||||
values.title = message[:20]
|
|
||||||
|
|
||||||
values.verbose = logging.WARNING - values.verbose * 10
|
|
||||||
|
|
||||||
return values, message
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
(options, message) = ClientParser().parse_args()
|
|
||||||
logging.basicConfig(level=options.verbose)
|
|
||||||
if not os.path.exists(DEFAULT_CONFIG):
|
|
||||||
logging.info('No config read found at %s', DEFAULT_CONFIG)
|
|
||||||
|
|
||||||
growl = GrowlNotifier(
|
|
||||||
applicationName=options.app,
|
|
||||||
notifications=[options.name],
|
|
||||||
defaultNotifications=[options.name],
|
|
||||||
hostname=options.host,
|
|
||||||
password=options.password,
|
|
||||||
port=options.port,
|
|
||||||
)
|
|
||||||
result = growl.register()
|
|
||||||
if result is not True:
|
|
||||||
exit(result)
|
|
||||||
|
|
||||||
# This would likely be better placed within the growl notifier
|
|
||||||
# class but until I make _checkIcon smarter this is "easier"
|
|
||||||
if options.icon is not None and not options.icon.startswith('http'):
|
|
||||||
logging.info('Loading image %s', options.icon)
|
|
||||||
f = open(options.icon)
|
|
||||||
options.icon = f.read()
|
|
||||||
f.close()
|
|
||||||
|
|
||||||
result = growl.notify(
|
|
||||||
noteType=options.name,
|
|
||||||
title=options.title,
|
|
||||||
description=message,
|
|
||||||
icon=options.icon,
|
|
||||||
sticky=options.sticky,
|
|
||||||
priority=options.priority,
|
|
||||||
callback=options.callback,
|
|
||||||
identifier=options.identifier,
|
|
||||||
)
|
|
||||||
if result is not True:
|
|
||||||
exit(result)
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
|
@ -1,77 +0,0 @@
|
||||||
# Copyright: 2013 Paul Traylor
|
|
||||||
# These sources are released under the terms of the MIT license: see LICENSE
|
|
||||||
|
|
||||||
"""
|
|
||||||
The gntp.config module is provided as an extended GrowlNotifier object that takes
|
|
||||||
advantage of the ConfigParser module to allow us to setup some default values
|
|
||||||
(such as hostname, password, and port) in a more global way to be shared among
|
|
||||||
programs using gntp
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
|
|
||||||
from .gntp import notifier
|
|
||||||
from .gntp import shim
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'mini',
|
|
||||||
'GrowlNotifier'
|
|
||||||
]
|
|
||||||
|
|
||||||
logger = logging.getLogger('gntp')
|
|
||||||
|
|
||||||
|
|
||||||
class GrowlNotifier(notifier.GrowlNotifier):
|
|
||||||
"""
|
|
||||||
ConfigParser enhanced GrowlNotifier object
|
|
||||||
|
|
||||||
For right now, we are only interested in letting users overide certain
|
|
||||||
values from ~/.gntp
|
|
||||||
|
|
||||||
::
|
|
||||||
|
|
||||||
[gntp]
|
|
||||||
hostname = ?
|
|
||||||
password = ?
|
|
||||||
port = ?
|
|
||||||
"""
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
config = shim.RawConfigParser({
|
|
||||||
'hostname': kwargs.get('hostname', 'localhost'),
|
|
||||||
'password': kwargs.get('password'),
|
|
||||||
'port': kwargs.get('port', 23053),
|
|
||||||
})
|
|
||||||
|
|
||||||
config.read([os.path.expanduser('~/.gntp')])
|
|
||||||
|
|
||||||
# If the file does not exist, then there will be no gntp section defined
|
|
||||||
# and the config.get() lines below will get confused. Since we are not
|
|
||||||
# saving the config, it should be safe to just add it here so the
|
|
||||||
# code below doesn't complain
|
|
||||||
if not config.has_section('gntp'):
|
|
||||||
logger.info('Error reading ~/.gntp config file')
|
|
||||||
config.add_section('gntp')
|
|
||||||
|
|
||||||
kwargs['password'] = config.get('gntp', 'password')
|
|
||||||
kwargs['hostname'] = config.get('gntp', 'hostname')
|
|
||||||
kwargs['port'] = config.getint('gntp', 'port')
|
|
||||||
|
|
||||||
super(GrowlNotifier, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
def mini(description, **kwargs):
|
|
||||||
"""Single notification function
|
|
||||||
|
|
||||||
Simple notification function in one line. Has only one required parameter
|
|
||||||
and attempts to use reasonable defaults for everything else
|
|
||||||
:param string description: Notification message
|
|
||||||
"""
|
|
||||||
kwargs['notifierFactory'] = GrowlNotifier
|
|
||||||
notifier.mini(description, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# If we're running this module directly we're likely running it as a test
|
|
||||||
# so extra debugging is useful
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
mini('Testing mini notification')
|
|
|
@ -1,511 +0,0 @@
|
||||||
# Copyright: 2013 Paul Traylor
|
|
||||||
# These sources are released under the terms of the MIT license: see LICENSE
|
|
||||||
|
|
||||||
import hashlib
|
|
||||||
import re
|
|
||||||
import time
|
|
||||||
|
|
||||||
from . import shim
|
|
||||||
from . import errors as errors
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'GNTPRegister',
|
|
||||||
'GNTPNotice',
|
|
||||||
'GNTPSubscribe',
|
|
||||||
'GNTPOK',
|
|
||||||
'GNTPError',
|
|
||||||
'parse_gntp',
|
|
||||||
]
|
|
||||||
|
|
||||||
#GNTP/<version> <messagetype> <encryptionAlgorithmID>[:<ivValue>][ <keyHashAlgorithmID>:<keyHash>.<salt>]
|
|
||||||
GNTP_INFO_LINE = re.compile(
|
|
||||||
r'GNTP/(?P<version>\d+\.\d+) (?P<messagetype>REGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)' +
|
|
||||||
r' (?P<encryptionAlgorithmID>[A-Z0-9]+(:(?P<ivValue>[A-F0-9]+))?) ?' +
|
|
||||||
r'((?P<keyHashAlgorithmID>[A-Z0-9]+):(?P<keyHash>[A-F0-9]+).(?P<salt>[A-F0-9]+))?\r\n',
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
GNTP_INFO_LINE_SHORT = re.compile(
|
|
||||||
r'GNTP/(?P<version>\d+\.\d+) (?P<messagetype>REGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)',
|
|
||||||
re.IGNORECASE
|
|
||||||
)
|
|
||||||
|
|
||||||
GNTP_HEADER = re.compile(r'([\w-]+):(.+)')
|
|
||||||
|
|
||||||
GNTP_EOL = shim.b('\r\n')
|
|
||||||
GNTP_SEP = shim.b(': ')
|
|
||||||
|
|
||||||
|
|
||||||
class _GNTPBuffer(shim.StringIO):
|
|
||||||
"""GNTP Buffer class"""
|
|
||||||
def writeln(self, value=None):
|
|
||||||
if value:
|
|
||||||
self.write(shim.b(value))
|
|
||||||
self.write(GNTP_EOL)
|
|
||||||
|
|
||||||
def writeheader(self, key, value):
|
|
||||||
if not isinstance(value, str):
|
|
||||||
value = str(value)
|
|
||||||
self.write(shim.b(key))
|
|
||||||
self.write(GNTP_SEP)
|
|
||||||
self.write(shim.b(value))
|
|
||||||
self.write(GNTP_EOL)
|
|
||||||
|
|
||||||
|
|
||||||
class _GNTPBase(object):
|
|
||||||
"""Base initilization
|
|
||||||
|
|
||||||
:param string messagetype: GNTP Message type
|
|
||||||
:param string version: GNTP Protocol version
|
|
||||||
:param string encription: Encryption protocol
|
|
||||||
"""
|
|
||||||
def __init__(self, messagetype=None, version='1.0', encryption=None):
|
|
||||||
self.info = {
|
|
||||||
'version': version,
|
|
||||||
'messagetype': messagetype,
|
|
||||||
'encryptionAlgorithmID': encryption
|
|
||||||
}
|
|
||||||
self.hash_algo = {
|
|
||||||
'MD5': hashlib.md5,
|
|
||||||
'SHA1': hashlib.sha1,
|
|
||||||
'SHA256': hashlib.sha256,
|
|
||||||
'SHA512': hashlib.sha512,
|
|
||||||
}
|
|
||||||
self.headers = {}
|
|
||||||
self.resources = {}
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return self.encode()
|
|
||||||
|
|
||||||
def _parse_info(self, data):
|
|
||||||
"""Parse the first line of a GNTP message to get security and other info values
|
|
||||||
|
|
||||||
:param string data: GNTP Message
|
|
||||||
:return dict: Parsed GNTP Info line
|
|
||||||
"""
|
|
||||||
|
|
||||||
match = GNTP_INFO_LINE.match(data)
|
|
||||||
|
|
||||||
if not match:
|
|
||||||
raise errors.ParseError('ERROR_PARSING_INFO_LINE')
|
|
||||||
|
|
||||||
info = match.groupdict()
|
|
||||||
if info['encryptionAlgorithmID'] == 'NONE':
|
|
||||||
info['encryptionAlgorithmID'] = None
|
|
||||||
|
|
||||||
return info
|
|
||||||
|
|
||||||
def set_password(self, password, encryptAlgo='MD5'):
|
|
||||||
"""Set a password for a GNTP Message
|
|
||||||
|
|
||||||
:param string password: Null to clear password
|
|
||||||
:param string encryptAlgo: Supports MD5, SHA1, SHA256, SHA512
|
|
||||||
"""
|
|
||||||
if not password:
|
|
||||||
self.info['encryptionAlgorithmID'] = None
|
|
||||||
self.info['keyHashAlgorithm'] = None
|
|
||||||
return
|
|
||||||
|
|
||||||
self.password = shim.b(password)
|
|
||||||
self.encryptAlgo = encryptAlgo.upper()
|
|
||||||
|
|
||||||
if not self.encryptAlgo in self.hash_algo:
|
|
||||||
raise errors.UnsupportedError('INVALID HASH "%s"' % self.encryptAlgo)
|
|
||||||
|
|
||||||
hashfunction = self.hash_algo.get(self.encryptAlgo)
|
|
||||||
|
|
||||||
password = password.encode('utf8')
|
|
||||||
seed = time.ctime().encode('utf8')
|
|
||||||
salt = hashfunction(seed).hexdigest()
|
|
||||||
saltHash = hashfunction(seed).digest()
|
|
||||||
keyBasis = password + saltHash
|
|
||||||
key = hashfunction(keyBasis).digest()
|
|
||||||
keyHash = hashfunction(key).hexdigest()
|
|
||||||
|
|
||||||
self.info['keyHashAlgorithmID'] = self.encryptAlgo
|
|
||||||
self.info['keyHash'] = keyHash.upper()
|
|
||||||
self.info['salt'] = salt.upper()
|
|
||||||
|
|
||||||
def _decode_hex(self, value):
|
|
||||||
"""Helper function to decode hex string to `proper` hex string
|
|
||||||
|
|
||||||
:param string value: Human readable hex string
|
|
||||||
:return string: Hex string
|
|
||||||
"""
|
|
||||||
result = ''
|
|
||||||
for i in range(0, len(value), 2):
|
|
||||||
tmp = int(value[i:i + 2], 16)
|
|
||||||
result += chr(tmp)
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _decode_binary(self, rawIdentifier, identifier):
|
|
||||||
rawIdentifier += '\r\n\r\n'
|
|
||||||
dataLength = int(identifier['Length'])
|
|
||||||
pointerStart = self.raw.find(rawIdentifier) + len(rawIdentifier)
|
|
||||||
pointerEnd = pointerStart + dataLength
|
|
||||||
data = self.raw[pointerStart:pointerEnd]
|
|
||||||
if not len(data) == dataLength:
|
|
||||||
raise errors.ParseError('INVALID_DATA_LENGTH Expected: %s Recieved %s' % (dataLength, len(data)))
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _validate_password(self, password):
|
|
||||||
"""Validate GNTP Message against stored password"""
|
|
||||||
self.password = password
|
|
||||||
if password is None:
|
|
||||||
raise errors.AuthError('Missing password')
|
|
||||||
keyHash = self.info.get('keyHash', None)
|
|
||||||
if keyHash is None and self.password is None:
|
|
||||||
return True
|
|
||||||
if keyHash is None:
|
|
||||||
raise errors.AuthError('Invalid keyHash')
|
|
||||||
if self.password is None:
|
|
||||||
raise errors.AuthError('Missing password')
|
|
||||||
|
|
||||||
keyHashAlgorithmID = self.info.get('keyHashAlgorithmID','MD5')
|
|
||||||
|
|
||||||
password = self.password.encode('utf8')
|
|
||||||
saltHash = self._decode_hex(self.info['salt'])
|
|
||||||
|
|
||||||
keyBasis = password + saltHash
|
|
||||||
self.key = self.hash_algo[keyHashAlgorithmID](keyBasis).digest()
|
|
||||||
keyHash = self.hash_algo[keyHashAlgorithmID](self.key).hexdigest()
|
|
||||||
|
|
||||||
if not keyHash.upper() == self.info['keyHash'].upper():
|
|
||||||
raise errors.AuthError('Invalid Hash')
|
|
||||||
return True
|
|
||||||
|
|
||||||
def validate(self):
|
|
||||||
"""Verify required headers"""
|
|
||||||
for header in self._requiredHeaders:
|
|
||||||
if not self.headers.get(header, False):
|
|
||||||
raise errors.ParseError('Missing Notification Header: ' + header)
|
|
||||||
|
|
||||||
def _format_info(self):
|
|
||||||
"""Generate info line for GNTP Message
|
|
||||||
|
|
||||||
:return string:
|
|
||||||
"""
|
|
||||||
info = 'GNTP/%s %s' % (
|
|
||||||
self.info.get('version'),
|
|
||||||
self.info.get('messagetype'),
|
|
||||||
)
|
|
||||||
if self.info.get('encryptionAlgorithmID', None):
|
|
||||||
info += ' %s:%s' % (
|
|
||||||
self.info.get('encryptionAlgorithmID'),
|
|
||||||
self.info.get('ivValue'),
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
info += ' NONE'
|
|
||||||
|
|
||||||
if self.info.get('keyHashAlgorithmID', None):
|
|
||||||
info += ' %s:%s.%s' % (
|
|
||||||
self.info.get('keyHashAlgorithmID'),
|
|
||||||
self.info.get('keyHash'),
|
|
||||||
self.info.get('salt')
|
|
||||||
)
|
|
||||||
|
|
||||||
return info
|
|
||||||
|
|
||||||
def _parse_dict(self, data):
|
|
||||||
"""Helper function to parse blocks of GNTP headers into a dictionary
|
|
||||||
|
|
||||||
:param string data:
|
|
||||||
:return dict: Dictionary of parsed GNTP Headers
|
|
||||||
"""
|
|
||||||
d = {}
|
|
||||||
for line in data.split('\r\n'):
|
|
||||||
match = GNTP_HEADER.match(line)
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
|
|
||||||
key = match.group(1).strip()
|
|
||||||
val = match.group(2).strip()
|
|
||||||
d[key] = val
|
|
||||||
return d
|
|
||||||
|
|
||||||
def add_header(self, key, value):
|
|
||||||
self.headers[key] = value
|
|
||||||
|
|
||||||
def add_resource(self, data):
|
|
||||||
"""Add binary resource
|
|
||||||
|
|
||||||
:param string data: Binary Data
|
|
||||||
"""
|
|
||||||
data = shim.b(data)
|
|
||||||
identifier = hashlib.md5(data).hexdigest()
|
|
||||||
self.resources[identifier] = data
|
|
||||||
return 'x-growl-resource://%s' % identifier
|
|
||||||
|
|
||||||
def decode(self, data, password=None):
|
|
||||||
"""Decode GNTP Message
|
|
||||||
|
|
||||||
:param string data:
|
|
||||||
"""
|
|
||||||
self.password = password
|
|
||||||
self.raw = shim.u(data)
|
|
||||||
parts = self.raw.split('\r\n\r\n')
|
|
||||||
self.info = self._parse_info(self.raw)
|
|
||||||
self.headers = self._parse_dict(parts[0])
|
|
||||||
|
|
||||||
def encode(self):
|
|
||||||
"""Encode a generic GNTP Message
|
|
||||||
|
|
||||||
:return string: GNTP Message ready to be sent. Returned as a byte string
|
|
||||||
"""
|
|
||||||
|
|
||||||
buff = _GNTPBuffer()
|
|
||||||
|
|
||||||
buff.writeln(self._format_info())
|
|
||||||
|
|
||||||
#Headers
|
|
||||||
for k, v in self.headers.items():
|
|
||||||
buff.writeheader(k, v)
|
|
||||||
buff.writeln()
|
|
||||||
|
|
||||||
#Resources
|
|
||||||
for resource, data in self.resources.items():
|
|
||||||
buff.writeheader('Identifier', resource)
|
|
||||||
buff.writeheader('Length', len(data))
|
|
||||||
buff.writeln()
|
|
||||||
buff.write(data)
|
|
||||||
buff.writeln()
|
|
||||||
buff.writeln()
|
|
||||||
|
|
||||||
return buff.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
class GNTPRegister(_GNTPBase):
|
|
||||||
"""Represents a GNTP Registration Command
|
|
||||||
|
|
||||||
:param string data: (Optional) See decode()
|
|
||||||
:param string password: (Optional) Password to use while encoding/decoding messages
|
|
||||||
"""
|
|
||||||
_requiredHeaders = [
|
|
||||||
'Application-Name',
|
|
||||||
'Notifications-Count'
|
|
||||||
]
|
|
||||||
_requiredNotificationHeaders = ['Notification-Name']
|
|
||||||
|
|
||||||
def __init__(self, data=None, password=None):
|
|
||||||
_GNTPBase.__init__(self, 'REGISTER')
|
|
||||||
self.notifications = []
|
|
||||||
|
|
||||||
if data:
|
|
||||||
self.decode(data, password)
|
|
||||||
else:
|
|
||||||
self.set_password(password)
|
|
||||||
self.add_header('Application-Name', 'pygntp')
|
|
||||||
self.add_header('Notifications-Count', 0)
|
|
||||||
|
|
||||||
def validate(self):
|
|
||||||
'''Validate required headers and validate notification headers'''
|
|
||||||
for header in self._requiredHeaders:
|
|
||||||
if not self.headers.get(header, False):
|
|
||||||
raise errors.ParseError('Missing Registration Header: ' + header)
|
|
||||||
for notice in self.notifications:
|
|
||||||
for header in self._requiredNotificationHeaders:
|
|
||||||
if not notice.get(header, False):
|
|
||||||
raise errors.ParseError('Missing Notification Header: ' + header)
|
|
||||||
|
|
||||||
def decode(self, data, password):
|
|
||||||
"""Decode existing GNTP Registration message
|
|
||||||
|
|
||||||
:param string data: Message to decode
|
|
||||||
"""
|
|
||||||
self.raw = shim.u(data)
|
|
||||||
parts = self.raw.split('\r\n\r\n')
|
|
||||||
self.info = self._parse_info(self.raw)
|
|
||||||
self._validate_password(password)
|
|
||||||
self.headers = self._parse_dict(parts[0])
|
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
|
||||||
if i == 0:
|
|
||||||
continue # Skip Header
|
|
||||||
if part.strip() == '':
|
|
||||||
continue
|
|
||||||
notice = self._parse_dict(part)
|
|
||||||
if notice.get('Notification-Name', False):
|
|
||||||
self.notifications.append(notice)
|
|
||||||
elif notice.get('Identifier', False):
|
|
||||||
notice['Data'] = self._decode_binary(part, notice)
|
|
||||||
#open('register.png','wblol').write(notice['Data'])
|
|
||||||
self.resources[notice.get('Identifier')] = notice
|
|
||||||
|
|
||||||
def add_notification(self, name, enabled=True):
|
|
||||||
"""Add new Notification to Registration message
|
|
||||||
|
|
||||||
:param string name: Notification Name
|
|
||||||
:param boolean enabled: Enable this notification by default
|
|
||||||
"""
|
|
||||||
notice = {}
|
|
||||||
notice['Notification-Name'] = name
|
|
||||||
notice['Notification-Enabled'] = enabled
|
|
||||||
|
|
||||||
self.notifications.append(notice)
|
|
||||||
self.add_header('Notifications-Count', len(self.notifications))
|
|
||||||
|
|
||||||
def encode(self):
|
|
||||||
"""Encode a GNTP Registration Message
|
|
||||||
|
|
||||||
:return string: Encoded GNTP Registration message. Returned as a byte string
|
|
||||||
"""
|
|
||||||
|
|
||||||
buff = _GNTPBuffer()
|
|
||||||
|
|
||||||
buff.writeln(self._format_info())
|
|
||||||
|
|
||||||
#Headers
|
|
||||||
for k, v in self.headers.items():
|
|
||||||
buff.writeheader(k, v)
|
|
||||||
buff.writeln()
|
|
||||||
|
|
||||||
#Notifications
|
|
||||||
if len(self.notifications) > 0:
|
|
||||||
for notice in self.notifications:
|
|
||||||
for k, v in notice.items():
|
|
||||||
buff.writeheader(k, v)
|
|
||||||
buff.writeln()
|
|
||||||
|
|
||||||
#Resources
|
|
||||||
for resource, data in self.resources.items():
|
|
||||||
buff.writeheader('Identifier', resource)
|
|
||||||
buff.writeheader('Length', len(data))
|
|
||||||
buff.writeln()
|
|
||||||
buff.write(data)
|
|
||||||
buff.writeln()
|
|
||||||
buff.writeln()
|
|
||||||
|
|
||||||
return buff.getvalue()
|
|
||||||
|
|
||||||
|
|
||||||
class GNTPNotice(_GNTPBase):
|
|
||||||
"""Represents a GNTP Notification Command
|
|
||||||
|
|
||||||
:param string data: (Optional) See decode()
|
|
||||||
:param string app: (Optional) Set Application-Name
|
|
||||||
:param string name: (Optional) Set Notification-Name
|
|
||||||
:param string title: (Optional) Set Notification Title
|
|
||||||
:param string password: (Optional) Password to use while encoding/decoding messages
|
|
||||||
"""
|
|
||||||
_requiredHeaders = [
|
|
||||||
'Application-Name',
|
|
||||||
'Notification-Name',
|
|
||||||
'Notification-Title'
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, data=None, app=None, name=None, title=None, password=None):
|
|
||||||
_GNTPBase.__init__(self, 'NOTIFY')
|
|
||||||
|
|
||||||
if data:
|
|
||||||
self.decode(data, password)
|
|
||||||
else:
|
|
||||||
self.set_password(password)
|
|
||||||
if app:
|
|
||||||
self.add_header('Application-Name', app)
|
|
||||||
if name:
|
|
||||||
self.add_header('Notification-Name', name)
|
|
||||||
if title:
|
|
||||||
self.add_header('Notification-Title', title)
|
|
||||||
|
|
||||||
def decode(self, data, password):
|
|
||||||
"""Decode existing GNTP Notification message
|
|
||||||
|
|
||||||
:param string data: Message to decode.
|
|
||||||
"""
|
|
||||||
self.raw = shim.u(data)
|
|
||||||
parts = self.raw.split('\r\n\r\n')
|
|
||||||
self.info = self._parse_info(self.raw)
|
|
||||||
self._validate_password(password)
|
|
||||||
self.headers = self._parse_dict(parts[0])
|
|
||||||
|
|
||||||
for i, part in enumerate(parts):
|
|
||||||
if i == 0:
|
|
||||||
continue # Skip Header
|
|
||||||
if part.strip() == '':
|
|
||||||
continue
|
|
||||||
notice = self._parse_dict(part)
|
|
||||||
if notice.get('Identifier', False):
|
|
||||||
notice['Data'] = self._decode_binary(part, notice)
|
|
||||||
#open('notice.png','wblol').write(notice['Data'])
|
|
||||||
self.resources[notice.get('Identifier')] = notice
|
|
||||||
|
|
||||||
|
|
||||||
class GNTPSubscribe(_GNTPBase):
|
|
||||||
"""Represents a GNTP Subscribe Command
|
|
||||||
|
|
||||||
:param string data: (Optional) See decode()
|
|
||||||
:param string password: (Optional) Password to use while encoding/decoding messages
|
|
||||||
"""
|
|
||||||
_requiredHeaders = [
|
|
||||||
'Subscriber-ID',
|
|
||||||
'Subscriber-Name',
|
|
||||||
]
|
|
||||||
|
|
||||||
def __init__(self, data=None, password=None):
|
|
||||||
_GNTPBase.__init__(self, 'SUBSCRIBE')
|
|
||||||
if data:
|
|
||||||
self.decode(data, password)
|
|
||||||
else:
|
|
||||||
self.set_password(password)
|
|
||||||
|
|
||||||
|
|
||||||
class GNTPOK(_GNTPBase):
|
|
||||||
"""Represents a GNTP OK Response
|
|
||||||
|
|
||||||
:param string data: (Optional) See _GNTPResponse.decode()
|
|
||||||
:param string action: (Optional) Set type of action the OK Response is for
|
|
||||||
"""
|
|
||||||
_requiredHeaders = ['Response-Action']
|
|
||||||
|
|
||||||
def __init__(self, data=None, action=None):
|
|
||||||
_GNTPBase.__init__(self, '-OK')
|
|
||||||
if data:
|
|
||||||
self.decode(data)
|
|
||||||
if action:
|
|
||||||
self.add_header('Response-Action', action)
|
|
||||||
|
|
||||||
|
|
||||||
class GNTPError(_GNTPBase):
|
|
||||||
"""Represents a GNTP Error response
|
|
||||||
|
|
||||||
:param string data: (Optional) See _GNTPResponse.decode()
|
|
||||||
:param string errorcode: (Optional) Error code
|
|
||||||
:param string errordesc: (Optional) Error Description
|
|
||||||
"""
|
|
||||||
_requiredHeaders = ['Error-Code', 'Error-Description']
|
|
||||||
|
|
||||||
def __init__(self, data=None, errorcode=None, errordesc=None):
|
|
||||||
_GNTPBase.__init__(self, '-ERROR')
|
|
||||||
if data:
|
|
||||||
self.decode(data)
|
|
||||||
if errorcode:
|
|
||||||
self.add_header('Error-Code', errorcode)
|
|
||||||
self.add_header('Error-Description', errordesc)
|
|
||||||
|
|
||||||
def error(self):
|
|
||||||
return (self.headers.get('Error-Code', None),
|
|
||||||
self.headers.get('Error-Description', None))
|
|
||||||
|
|
||||||
|
|
||||||
def parse_gntp(data, password=None):
|
|
||||||
"""Attempt to parse a message as a GNTP message
|
|
||||||
|
|
||||||
:param string data: Message to be parsed
|
|
||||||
:param string password: Optional password to be used to verify the message
|
|
||||||
"""
|
|
||||||
data = shim.u(data)
|
|
||||||
match = GNTP_INFO_LINE_SHORT.match(data)
|
|
||||||
if not match:
|
|
||||||
raise errors.ParseError('INVALID_GNTP_INFO')
|
|
||||||
info = match.groupdict()
|
|
||||||
if info['messagetype'] == 'REGISTER':
|
|
||||||
return GNTPRegister(data, password=password)
|
|
||||||
elif info['messagetype'] == 'NOTIFY':
|
|
||||||
return GNTPNotice(data, password=password)
|
|
||||||
elif info['messagetype'] == 'SUBSCRIBE':
|
|
||||||
return GNTPSubscribe(data, password=password)
|
|
||||||
elif info['messagetype'] == '-OK':
|
|
||||||
return GNTPOK(data)
|
|
||||||
elif info['messagetype'] == '-ERROR':
|
|
||||||
return GNTPError(data)
|
|
||||||
raise errors.ParseError('INVALID_GNTP_MESSAGE')
|
|
|
@ -1,25 +0,0 @@
|
||||||
# Copyright: 2013 Paul Traylor
|
|
||||||
# These sources are released under the terms of the MIT license: see LICENSE
|
|
||||||
|
|
||||||
class BaseError(Exception):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class ParseError(BaseError):
|
|
||||||
errorcode = 500
|
|
||||||
errordesc = 'Error parsing the message'
|
|
||||||
|
|
||||||
|
|
||||||
class AuthError(BaseError):
|
|
||||||
errorcode = 400
|
|
||||||
errordesc = 'Error with authorization'
|
|
||||||
|
|
||||||
|
|
||||||
class UnsupportedError(BaseError):
|
|
||||||
errorcode = 500
|
|
||||||
errordesc = 'Currently unsupported by gntp.py'
|
|
||||||
|
|
||||||
|
|
||||||
class NetworkError(BaseError):
|
|
||||||
errorcode = 500
|
|
||||||
errordesc = "Error connecting to growl server"
|
|
|
@ -1,265 +0,0 @@
|
||||||
# Copyright: 2013 Paul Traylor
|
|
||||||
# These sources are released under the terms of the MIT license: see LICENSE
|
|
||||||
|
|
||||||
"""
|
|
||||||
The gntp.notifier module is provided as a simple way to send notifications
|
|
||||||
using GNTP
|
|
||||||
|
|
||||||
.. note::
|
|
||||||
This class is intended to mostly mirror the older Python bindings such
|
|
||||||
that you should be able to replace instances of the old bindings with
|
|
||||||
this class.
|
|
||||||
`Original Python bindings <http://code.google.com/p/growl/source/browse/Bindings/python/Growl.py>`_
|
|
||||||
|
|
||||||
"""
|
|
||||||
import logging
|
|
||||||
import platform
|
|
||||||
import socket
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from .version import __version__
|
|
||||||
from . import core
|
|
||||||
from . import errors as errors
|
|
||||||
from . import shim
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'mini',
|
|
||||||
'GrowlNotifier',
|
|
||||||
]
|
|
||||||
|
|
||||||
logger = logging.getLogger('gntp')
|
|
||||||
|
|
||||||
|
|
||||||
class GrowlNotifier(object):
|
|
||||||
"""Helper class to simplfy sending Growl messages
|
|
||||||
|
|
||||||
:param string applicationName: Sending application name
|
|
||||||
:param list notification: List of valid notifications
|
|
||||||
:param list defaultNotifications: List of notifications that should be enabled
|
|
||||||
by default
|
|
||||||
:param string applicationIcon: Icon URL
|
|
||||||
:param string hostname: Remote host
|
|
||||||
:param integer port: Remote port
|
|
||||||
"""
|
|
||||||
|
|
||||||
passwordHash = 'MD5'
|
|
||||||
socketTimeout = 3
|
|
||||||
|
|
||||||
def __init__(self, applicationName='Python GNTP', notifications=[],
|
|
||||||
defaultNotifications=None, applicationIcon=None, hostname='localhost',
|
|
||||||
password=None, port=23053):
|
|
||||||
|
|
||||||
self.applicationName = applicationName
|
|
||||||
self.notifications = list(notifications)
|
|
||||||
if defaultNotifications:
|
|
||||||
self.defaultNotifications = list(defaultNotifications)
|
|
||||||
else:
|
|
||||||
self.defaultNotifications = self.notifications
|
|
||||||
self.applicationIcon = applicationIcon
|
|
||||||
|
|
||||||
self.password = password
|
|
||||||
self.hostname = hostname
|
|
||||||
self.port = int(port)
|
|
||||||
|
|
||||||
def _checkIcon(self, data):
|
|
||||||
'''
|
|
||||||
Check the icon to see if it's valid
|
|
||||||
|
|
||||||
If it's a simple URL icon, then we return True. If it's a data icon
|
|
||||||
then we return False
|
|
||||||
'''
|
|
||||||
logger.info('Checking icon')
|
|
||||||
return shim.u(data).startswith('http')
|
|
||||||
|
|
||||||
def register(self):
|
|
||||||
"""Send GNTP Registration
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
Before sending notifications to Growl, you need to have
|
|
||||||
sent a registration message at least once
|
|
||||||
"""
|
|
||||||
logger.info('Sending registration to %s:%s', self.hostname, self.port)
|
|
||||||
register = core.GNTPRegister()
|
|
||||||
register.add_header('Application-Name', self.applicationName)
|
|
||||||
for notification in self.notifications:
|
|
||||||
enabled = notification in self.defaultNotifications
|
|
||||||
register.add_notification(notification, enabled)
|
|
||||||
if self.applicationIcon:
|
|
||||||
if self._checkIcon(self.applicationIcon):
|
|
||||||
register.add_header('Application-Icon', self.applicationIcon)
|
|
||||||
else:
|
|
||||||
resource = register.add_resource(self.applicationIcon)
|
|
||||||
register.add_header('Application-Icon', resource)
|
|
||||||
if self.password:
|
|
||||||
register.set_password(self.password, self.passwordHash)
|
|
||||||
self.add_origin_info(register)
|
|
||||||
self.register_hook(register)
|
|
||||||
return self._send('register', register)
|
|
||||||
|
|
||||||
def notify(self, noteType, title, description, icon=None, sticky=False,
|
|
||||||
priority=None, callback=None, identifier=None, custom={}):
|
|
||||||
"""Send a GNTP notifications
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
Must have registered with growl beforehand or messages will be ignored
|
|
||||||
|
|
||||||
:param string noteType: One of the notification names registered earlier
|
|
||||||
:param string title: Notification title (usually displayed on the notification)
|
|
||||||
:param string description: The main content of the notification
|
|
||||||
:param string icon: Icon URL path
|
|
||||||
:param boolean sticky: Sticky notification
|
|
||||||
:param integer priority: Message priority level from -2 to 2
|
|
||||||
:param string callback: URL callback
|
|
||||||
:param dict custom: Custom attributes. Key names should be prefixed with X-
|
|
||||||
according to the spec but this is not enforced by this class
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
For now, only URL callbacks are supported. In the future, the
|
|
||||||
callback argument will also support a function
|
|
||||||
"""
|
|
||||||
logger.info('Sending notification [%s] to %s:%s', noteType, self.hostname, self.port)
|
|
||||||
assert noteType in self.notifications
|
|
||||||
notice = core.GNTPNotice()
|
|
||||||
notice.add_header('Application-Name', self.applicationName)
|
|
||||||
notice.add_header('Notification-Name', noteType)
|
|
||||||
notice.add_header('Notification-Title', title)
|
|
||||||
if self.password:
|
|
||||||
notice.set_password(self.password, self.passwordHash)
|
|
||||||
if sticky:
|
|
||||||
notice.add_header('Notification-Sticky', sticky)
|
|
||||||
if priority:
|
|
||||||
notice.add_header('Notification-Priority', priority)
|
|
||||||
if icon:
|
|
||||||
if self._checkIcon(icon):
|
|
||||||
notice.add_header('Notification-Icon', icon)
|
|
||||||
else:
|
|
||||||
resource = notice.add_resource(icon)
|
|
||||||
notice.add_header('Notification-Icon', resource)
|
|
||||||
|
|
||||||
if description:
|
|
||||||
notice.add_header('Notification-Text', description)
|
|
||||||
if callback:
|
|
||||||
notice.add_header('Notification-Callback-Target', callback)
|
|
||||||
if identifier:
|
|
||||||
notice.add_header('Notification-Coalescing-ID', identifier)
|
|
||||||
|
|
||||||
for key in custom:
|
|
||||||
notice.add_header(key, custom[key])
|
|
||||||
|
|
||||||
self.add_origin_info(notice)
|
|
||||||
self.notify_hook(notice)
|
|
||||||
|
|
||||||
return self._send('notify', notice)
|
|
||||||
|
|
||||||
def subscribe(self, id, name, port):
|
|
||||||
"""Send a Subscribe request to a remote machine"""
|
|
||||||
sub = core.GNTPSubscribe()
|
|
||||||
sub.add_header('Subscriber-ID', id)
|
|
||||||
sub.add_header('Subscriber-Name', name)
|
|
||||||
sub.add_header('Subscriber-Port', port)
|
|
||||||
if self.password:
|
|
||||||
sub.set_password(self.password, self.passwordHash)
|
|
||||||
|
|
||||||
self.add_origin_info(sub)
|
|
||||||
self.subscribe_hook(sub)
|
|
||||||
|
|
||||||
return self._send('subscribe', sub)
|
|
||||||
|
|
||||||
def add_origin_info(self, packet):
|
|
||||||
"""Add optional Origin headers to message"""
|
|
||||||
packet.add_header('Origin-Machine-Name', platform.node())
|
|
||||||
packet.add_header('Origin-Software-Name', 'gntp.py')
|
|
||||||
packet.add_header('Origin-Software-Version', __version__)
|
|
||||||
packet.add_header('Origin-Platform-Name', platform.system())
|
|
||||||
packet.add_header('Origin-Platform-Version', platform.platform())
|
|
||||||
|
|
||||||
def register_hook(self, packet):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def notify_hook(self, packet):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def subscribe_hook(self, packet):
|
|
||||||
pass
|
|
||||||
|
|
||||||
def _send(self, messagetype, packet):
|
|
||||||
"""Send the GNTP Packet"""
|
|
||||||
|
|
||||||
packet.validate()
|
|
||||||
data = packet.encode()
|
|
||||||
|
|
||||||
logger.debug('To : %s:%s <%s>\n%s', self.hostname, self.port, packet.__class__, data)
|
|
||||||
|
|
||||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
s.settimeout(self.socketTimeout)
|
|
||||||
try:
|
|
||||||
s.connect((self.hostname, self.port))
|
|
||||||
s.send(data)
|
|
||||||
recv_data = s.recv(1024)
|
|
||||||
while not recv_data.endswith(shim.b("\r\n\r\n")):
|
|
||||||
recv_data += s.recv(1024)
|
|
||||||
except socket.error:
|
|
||||||
# Python2.5 and Python3 compatibile exception
|
|
||||||
exc = sys.exc_info()[1]
|
|
||||||
raise errors.NetworkError(exc)
|
|
||||||
|
|
||||||
response = core.parse_gntp(recv_data)
|
|
||||||
s.close()
|
|
||||||
|
|
||||||
logger.debug('From : %s:%s <%s>\n%s', self.hostname, self.port, response.__class__, response)
|
|
||||||
|
|
||||||
if type(response) == core.GNTPOK:
|
|
||||||
return True
|
|
||||||
logger.error('Invalid response: %s', response.error())
|
|
||||||
return response.error()
|
|
||||||
|
|
||||||
|
|
||||||
def mini(description, applicationName='PythonMini', noteType="Message",
|
|
||||||
title="Mini Message", applicationIcon=None, hostname='localhost',
|
|
||||||
password=None, port=23053, sticky=False, priority=None,
|
|
||||||
callback=None, notificationIcon=None, identifier=None,
|
|
||||||
notifierFactory=GrowlNotifier):
|
|
||||||
"""Single notification function
|
|
||||||
|
|
||||||
Simple notification function in one line. Has only one required parameter
|
|
||||||
and attempts to use reasonable defaults for everything else
|
|
||||||
:param string description: Notification message
|
|
||||||
|
|
||||||
.. warning::
|
|
||||||
For now, only URL callbacks are supported. In the future, the
|
|
||||||
callback argument will also support a function
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
growl = notifierFactory(
|
|
||||||
applicationName=applicationName,
|
|
||||||
notifications=[noteType],
|
|
||||||
defaultNotifications=[noteType],
|
|
||||||
applicationIcon=applicationIcon,
|
|
||||||
hostname=hostname,
|
|
||||||
password=password,
|
|
||||||
port=port,
|
|
||||||
)
|
|
||||||
result = growl.register()
|
|
||||||
if result is not True:
|
|
||||||
return result
|
|
||||||
|
|
||||||
return growl.notify(
|
|
||||||
noteType=noteType,
|
|
||||||
title=title,
|
|
||||||
description=description,
|
|
||||||
icon=notificationIcon,
|
|
||||||
sticky=sticky,
|
|
||||||
priority=priority,
|
|
||||||
callback=callback,
|
|
||||||
identifier=identifier,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
# We want the "mini" function to be simple and swallow Exceptions
|
|
||||||
# in order to be less invasive
|
|
||||||
logger.exception("Growl error")
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# If we're running this module directly we're likely running it as a test
|
|
||||||
# so extra debugging is useful
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
|
||||||
mini('Testing mini notification')
|
|
|
@ -1,45 +0,0 @@
|
||||||
# Copyright: 2013 Paul Traylor
|
|
||||||
# These sources are released under the terms of the MIT license: see LICENSE
|
|
||||||
|
|
||||||
"""
|
|
||||||
Python2.5 and Python3.3 compatibility shim
|
|
||||||
|
|
||||||
Heavily inspirted by the "six" library.
|
|
||||||
https://pypi.python.org/pypi/six
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
|
||||||
|
|
||||||
PY3 = sys.version_info[0] == 3
|
|
||||||
|
|
||||||
if PY3:
|
|
||||||
def b(s):
|
|
||||||
if isinstance(s, bytes):
|
|
||||||
return s
|
|
||||||
return s.encode('utf8', 'replace')
|
|
||||||
|
|
||||||
def u(s):
|
|
||||||
if isinstance(s, bytes):
|
|
||||||
return s.decode('utf8', 'replace')
|
|
||||||
return s
|
|
||||||
|
|
||||||
from io import BytesIO as StringIO
|
|
||||||
from configparser import RawConfigParser
|
|
||||||
else:
|
|
||||||
def b(s):
|
|
||||||
if isinstance(s, unicode): # noqa
|
|
||||||
return s.encode('utf8', 'replace')
|
|
||||||
return s
|
|
||||||
|
|
||||||
def u(s):
|
|
||||||
if isinstance(s, unicode): # noqa
|
|
||||||
return s
|
|
||||||
if isinstance(s, int):
|
|
||||||
s = str(s)
|
|
||||||
return unicode(s, "utf8", "replace") # noqa
|
|
||||||
|
|
||||||
from StringIO import StringIO
|
|
||||||
from ConfigParser import RawConfigParser
|
|
||||||
|
|
||||||
b.__doc__ = "Ensure we have a byte string"
|
|
||||||
u.__doc__ = "Ensure we have a unicode string"
|
|
|
@ -1,4 +0,0 @@
|
||||||
# Copyright: 2013 Paul Traylor
|
|
||||||
# These sources are released under the terms of the MIT license: see LICENSE
|
|
||||||
|
|
||||||
__version__ = '1.0.2'
|
|
|
@ -33,7 +33,6 @@ from os.path import abspath
|
||||||
|
|
||||||
# Used for testing
|
# Used for testing
|
||||||
from . import NotifyEmail as NotifyEmailBase
|
from . import NotifyEmail as NotifyEmailBase
|
||||||
from .NotifyGrowl import gntp
|
|
||||||
from .NotifyXMPP import SleekXmppAdapter
|
from .NotifyXMPP import SleekXmppAdapter
|
||||||
|
|
||||||
# NotifyBase object is passed in as a module not class
|
# NotifyBase object is passed in as a module not class
|
||||||
|
@ -63,9 +62,6 @@ __all__ = [
|
||||||
# Tokenizer
|
# Tokenizer
|
||||||
'url_to_dict',
|
'url_to_dict',
|
||||||
|
|
||||||
# gntp (used for NotifyGrowl Testing)
|
|
||||||
'gntp',
|
|
||||||
|
|
||||||
# sleekxmpp access points (used for NotifyXMPP Testing)
|
# sleekxmpp access points (used for NotifyXMPP Testing)
|
||||||
'SleekXmppAdapter',
|
'SleekXmppAdapter',
|
||||||
]
|
]
|
||||||
|
|
|
@ -9,3 +9,4 @@ cryptography
|
||||||
|
|
||||||
# Plugin Dependencies
|
# Plugin Dependencies
|
||||||
sleekxmpp
|
sleekxmpp
|
||||||
|
gntp
|
||||||
|
|
|
@ -84,10 +84,12 @@ BuildRequires: python-markdown
|
||||||
%if 0%{?rhel} && 0%{?rhel} <= 7
|
%if 0%{?rhel} && 0%{?rhel} <= 7
|
||||||
BuildRequires: python-cryptography
|
BuildRequires: python-cryptography
|
||||||
BuildRequires: python-babel
|
BuildRequires: python-babel
|
||||||
|
BuildRequires: python-gntp
|
||||||
BuildRequires: python-yaml
|
BuildRequires: python-yaml
|
||||||
%else
|
%else
|
||||||
BuildRequires: python2-cryptography
|
BuildRequires: python2-cryptography
|
||||||
BuildRequires: python2-babel
|
BuildRequires: python2-babel
|
||||||
|
BuildRequires: python2-gntp
|
||||||
BuildRequires: python2-yaml
|
BuildRequires: python2-yaml
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
|
@ -96,8 +98,10 @@ Requires: python2-requests-oauthlib
|
||||||
Requires: python-six
|
Requires: python-six
|
||||||
Requires: python-markdown
|
Requires: python-markdown
|
||||||
%if 0%{?rhel} && 0%{?rhel} <= 7
|
%if 0%{?rhel} && 0%{?rhel} <= 7
|
||||||
|
Requires: python-gntp
|
||||||
Requires: python-yaml
|
Requires: python-yaml
|
||||||
%else
|
%else
|
||||||
|
Requires: python2-gntp
|
||||||
Requires: python2-yaml
|
Requires: python2-yaml
|
||||||
%endif
|
%endif
|
||||||
|
|
||||||
|
@ -140,6 +144,7 @@ BuildRequires: python%{python3_pkgversion}-requests-oauthlib
|
||||||
BuildRequires: python%{python3_pkgversion}-six
|
BuildRequires: python%{python3_pkgversion}-six
|
||||||
BuildRequires: python%{python3_pkgversion}-click >= 5.0
|
BuildRequires: python%{python3_pkgversion}-click >= 5.0
|
||||||
BuildRequires: python%{python3_pkgversion}-markdown
|
BuildRequires: python%{python3_pkgversion}-markdown
|
||||||
|
BuildRequires: python%{python3_pkgversion}-gntp
|
||||||
BuildRequires: python%{python3_pkgversion}-yaml
|
BuildRequires: python%{python3_pkgversion}-yaml
|
||||||
BuildRequires: python%{python3_pkgversion}-babel
|
BuildRequires: python%{python3_pkgversion}-babel
|
||||||
BuildRequires: python%{python3_pkgversion}-cryptography
|
BuildRequires: python%{python3_pkgversion}-cryptography
|
||||||
|
@ -147,6 +152,7 @@ Requires: python%{python3_pkgversion}-requests
|
||||||
Requires: python%{python3_pkgversion}-requests-oauthlib
|
Requires: python%{python3_pkgversion}-requests-oauthlib
|
||||||
Requires: python%{python3_pkgversion}-six
|
Requires: python%{python3_pkgversion}-six
|
||||||
Requires: python%{python3_pkgversion}-markdown
|
Requires: python%{python3_pkgversion}-markdown
|
||||||
|
Requires: python%{python3_pkgversion}-gntp
|
||||||
Requires: python%{python3_pkgversion}-yaml
|
Requires: python%{python3_pkgversion}-yaml
|
||||||
|
|
||||||
%if %{with tests}
|
%if %{with tests}
|
||||||
|
|
|
@ -7,7 +7,7 @@ license_file = LICENSE
|
||||||
|
|
||||||
[flake8]
|
[flake8]
|
||||||
# We exclude packages we don't maintain
|
# We exclude packages we don't maintain
|
||||||
exclude = .eggs,.tox,gntp
|
exclude = .eggs,.tox
|
||||||
ignore = E741,E722,W503,W504,W605
|
ignore = E741,E722,W503,W504,W605
|
||||||
statistics = true
|
statistics = true
|
||||||
builtins = _
|
builtins = _
|
||||||
|
|
|
@ -23,127 +23,287 @@
|
||||||
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
# THE SOFTWARE.
|
# THE SOFTWARE.
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
import mock
|
import mock
|
||||||
import six
|
import six
|
||||||
from apprise import plugins
|
import pytest
|
||||||
from apprise import NotifyType
|
import apprise
|
||||||
from apprise import Apprise
|
|
||||||
|
|
||||||
# Disable logging for a cleaner testing output
|
# Disable logging for a cleaner testing output
|
||||||
import logging
|
import logging
|
||||||
logging.disable(logging.CRITICAL)
|
logging.disable(logging.CRITICAL)
|
||||||
|
|
||||||
|
try:
|
||||||
TEST_URLS = (
|
# Python v3.4+
|
||||||
##################################
|
from importlib import reload
|
||||||
# NotifyGrowl
|
except ImportError:
|
||||||
##################################
|
try:
|
||||||
('growl://', {
|
# Python v3.0-v3.3
|
||||||
'instance': None,
|
from imp import reload
|
||||||
}),
|
except ImportError:
|
||||||
('growl://:@/', {
|
# Python v2.7
|
||||||
'instance': None
|
pass
|
||||||
}),
|
|
||||||
|
|
||||||
('growl://pass@growl.server', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://ignored:pass@growl.server', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://growl.server', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
# don't include an image by default
|
|
||||||
'include_image': False,
|
|
||||||
}),
|
|
||||||
('growl://growl.server?version=1', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
# Force a failure
|
|
||||||
('growl://growl.server?version=1', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
'growl_response': None,
|
|
||||||
}),
|
|
||||||
('growl://growl.server?version=2', {
|
|
||||||
# don't include an image by default
|
|
||||||
'include_image': False,
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://growl.server?version=2', {
|
|
||||||
# don't include an image by default
|
|
||||||
'include_image': False,
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
'growl_response': None,
|
|
||||||
}),
|
|
||||||
|
|
||||||
# Priorities
|
|
||||||
('growl://pass@growl.server?priority=low', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://pass@growl.server?priority=moderate', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://pass@growl.server?priority=normal', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://pass@growl.server?priority=high', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://pass@growl.server?priority=emergency', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
|
|
||||||
# Invalid Priorities
|
|
||||||
('growl://pass@growl.server?priority=invalid', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://pass@growl.server?priority=', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
|
|
||||||
# invalid version
|
|
||||||
('growl://growl.server?version=', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://growl.server?version=crap', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
|
|
||||||
# Ports
|
|
||||||
('growl://growl.changeport:2000', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://growl.garbageport:garbage', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
('growl://growl.colon:', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
}),
|
|
||||||
# Exceptions
|
|
||||||
('growl://growl.exceptions01', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
# Throws a series of connection and transfer exceptions when this flag
|
|
||||||
# is set and tests that we gracfully handle them
|
|
||||||
'test_growl_notify_exceptions': True,
|
|
||||||
}),
|
|
||||||
('growl://growl.exceptions02', {
|
|
||||||
'instance': plugins.NotifyGrowl,
|
|
||||||
# Throws a series of connection and transfer exceptions when this flag
|
|
||||||
# is set and tests that we gracfully handle them
|
|
||||||
'test_growl_register_exceptions': True,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@mock.patch('apprise.plugins.gntp.notifier.GrowlNotifier')
|
try:
|
||||||
|
from gntp import errors
|
||||||
|
|
||||||
|
TEST_GROWL_EXCEPTIONS = (
|
||||||
|
errors.NetworkError(
|
||||||
|
0, 'gntp.ParseError() not handled'),
|
||||||
|
errors.AuthError(
|
||||||
|
0, 'gntp.AuthError() not handled'),
|
||||||
|
errors.ParseError(
|
||||||
|
0, 'gntp.ParseError() not handled'),
|
||||||
|
errors.UnsupportedError(
|
||||||
|
0, 'gntp.UnsupportedError() not handled'),
|
||||||
|
)
|
||||||
|
except ImportError:
|
||||||
|
# no problem; these tests will be skipped at this point
|
||||||
|
TEST_GROWL_EXCEPTIONS = tuple()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif('gntp' not in sys.modules, reason="requires gntp")
|
||||||
|
def test_growl_plugin_import_error(tmpdir):
|
||||||
|
"""
|
||||||
|
API: NotifyGrowl Plugin() Import Error
|
||||||
|
|
||||||
|
"""
|
||||||
|
# This is a really confusing test case; it can probably be done better,
|
||||||
|
# but this was all I could come up with. Effectively Apprise is will
|
||||||
|
# still work flawlessly without the gntp dependancy. Since
|
||||||
|
# gntp is actually required to be installed to run these unit tests
|
||||||
|
# we need to do some hacky tricks into fooling our test cases that the
|
||||||
|
# package isn't available.
|
||||||
|
|
||||||
|
# So we create a temporary directory called gntp (simulating the
|
||||||
|
# library itself) and writing an __init__.py in it that does nothing
|
||||||
|
# but throw an ImportError exception (simulating that the library
|
||||||
|
# isn't found).
|
||||||
|
suite = tmpdir.mkdir("gntp")
|
||||||
|
suite.join("__init__.py").write('')
|
||||||
|
module_name = 'gntp'
|
||||||
|
suite.join("{}.py".format(module_name)).write('raise ImportError()')
|
||||||
|
|
||||||
|
# The second part of the test is to update our PYTHON_PATH to look
|
||||||
|
# into this new directory first (before looking where the actual
|
||||||
|
# valid paths are). This will allow us to override 'JUST' the sleekxmpp
|
||||||
|
# path.
|
||||||
|
|
||||||
|
# Update our path to point to our new test suite
|
||||||
|
sys.path.insert(0, str(suite))
|
||||||
|
|
||||||
|
# We need to remove the gntp modules that have already been loaded
|
||||||
|
# in memory otherwise they'll just be used instead. Python is smart and
|
||||||
|
# won't go try and reload everything again if it doesn't have to.
|
||||||
|
for name in list(sys.modules.keys()):
|
||||||
|
if name.startswith('{}.'.format(module_name)):
|
||||||
|
del sys.modules[name]
|
||||||
|
del sys.modules[module_name]
|
||||||
|
|
||||||
|
# The following libraries need to be reloaded to prevent
|
||||||
|
# TypeError: super(type, obj): obj must be an instance or subtype of type
|
||||||
|
# This is better explained in this StackOverflow post:
|
||||||
|
# https://stackoverflow.com/questions/31363311/\
|
||||||
|
# any-way-to-manually-fix-operation-of-\
|
||||||
|
# super-after-ipython-reload-avoiding-ty
|
||||||
|
#
|
||||||
|
reload(sys.modules['apprise.plugins.NotifyGrowl'])
|
||||||
|
reload(sys.modules['apprise.plugins'])
|
||||||
|
reload(sys.modules['apprise.Apprise'])
|
||||||
|
reload(sys.modules['apprise'])
|
||||||
|
|
||||||
|
# This tests that Apprise still works without gntp.
|
||||||
|
obj = apprise.Apprise.instantiate('growl://growl.server')
|
||||||
|
|
||||||
|
# Growl objects can still be instantiated however
|
||||||
|
assert obj is not None
|
||||||
|
|
||||||
|
# Notifications won't work because gntp did not load
|
||||||
|
assert obj.notify(
|
||||||
|
title='test', body='body',
|
||||||
|
notify_type=apprise.NotifyType.INFO) is False
|
||||||
|
|
||||||
|
# Tidy-up / restore things to how they were
|
||||||
|
# Remove our garbage library
|
||||||
|
os.unlink(str(suite.join("{}.py".format(module_name))))
|
||||||
|
|
||||||
|
# Remove our custom entry into the path
|
||||||
|
sys.path.remove(str(suite))
|
||||||
|
|
||||||
|
# Reload the libraries we care about
|
||||||
|
reload(sys.modules['apprise.plugins.NotifyGrowl'])
|
||||||
|
reload(sys.modules['apprise.plugins'])
|
||||||
|
reload(sys.modules['apprise.Apprise'])
|
||||||
|
reload(sys.modules['apprise'])
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif('gntp' not in sys.modules, reason="requires gntp")
|
||||||
|
@mock.patch('gntp.notifier.GrowlNotifier')
|
||||||
|
def test_growl_exception_handling(mock_gntp):
|
||||||
|
"""
|
||||||
|
API: NotifyGrowl Exception Handling
|
||||||
|
"""
|
||||||
|
|
||||||
|
from gntp import errors
|
||||||
|
|
||||||
|
TEST_GROWL_EXCEPTIONS = (
|
||||||
|
errors.NetworkError(
|
||||||
|
0, 'gntp.ParseError() not handled'),
|
||||||
|
errors.AuthError(
|
||||||
|
0, 'gntp.AuthError() not handled'),
|
||||||
|
errors.ParseError(
|
||||||
|
0, 'gntp.ParseError() not handled'),
|
||||||
|
errors.UnsupportedError(
|
||||||
|
0, 'gntp.UnsupportedError() not handled'),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_notifier = mock.Mock()
|
||||||
|
mock_gntp.return_value = mock_notifier
|
||||||
|
mock_notifier.notify.return_value = True
|
||||||
|
|
||||||
|
# First we test the growl.register() function
|
||||||
|
for exception in TEST_GROWL_EXCEPTIONS:
|
||||||
|
mock_notifier.register.side_effect = exception
|
||||||
|
|
||||||
|
# instantiate our object
|
||||||
|
obj = apprise.Apprise.instantiate(
|
||||||
|
'growl://growl.server.hostname', suppress_exceptions=False)
|
||||||
|
|
||||||
|
# Verify Growl object was instantiated
|
||||||
|
assert obj is not None
|
||||||
|
|
||||||
|
# We will fail to send the notification because our registration
|
||||||
|
# would have failed
|
||||||
|
assert obj.notify(
|
||||||
|
title='test', body='body',
|
||||||
|
notify_type=apprise.NotifyType.INFO) is False
|
||||||
|
|
||||||
|
# Now we test the growl.notify() function
|
||||||
|
mock_notifier.register.side_effect = None
|
||||||
|
for exception in TEST_GROWL_EXCEPTIONS:
|
||||||
|
mock_notifier.notify.side_effect = exception
|
||||||
|
|
||||||
|
# instantiate our object
|
||||||
|
obj = apprise.Apprise.instantiate(
|
||||||
|
'growl://growl.server.hostname', suppress_exceptions=False)
|
||||||
|
|
||||||
|
# Verify Growl object was instantiated
|
||||||
|
assert obj is not None
|
||||||
|
|
||||||
|
# We will fail to send the notification because of the underlining
|
||||||
|
# notify() call throws an exception
|
||||||
|
assert obj.notify(
|
||||||
|
title='test', body='body',
|
||||||
|
notify_type=apprise.NotifyType.INFO) is False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(
|
||||||
|
'gntp' not in sys.modules, reason="requires gntp")
|
||||||
|
@mock.patch('gntp.notifier.GrowlNotifier')
|
||||||
def test_growl_plugin(mock_gntp):
|
def test_growl_plugin(mock_gntp):
|
||||||
"""
|
"""
|
||||||
API: NotifyGrowl Plugin()
|
API: NotifyGrowl Plugin()
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
urls = (
|
||||||
|
##################################
|
||||||
|
# NotifyGrowl
|
||||||
|
##################################
|
||||||
|
('growl://', {
|
||||||
|
'instance': None,
|
||||||
|
}),
|
||||||
|
('growl://:@/', {
|
||||||
|
'instance': None
|
||||||
|
}),
|
||||||
|
|
||||||
|
('growl://pass@growl.server', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://ignored:pass@growl.server', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://growl.server', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
# don't include an image by default
|
||||||
|
'include_image': False,
|
||||||
|
}),
|
||||||
|
('growl://growl.server?version=1', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
# Test sticky flag
|
||||||
|
('growl://growl.server?sticky=yes', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://growl.server?sticky=no', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
# Force a failure
|
||||||
|
('growl://growl.server?version=1', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
'growl_response': None,
|
||||||
|
}),
|
||||||
|
('growl://growl.server?version=2', {
|
||||||
|
# don't include an image by default
|
||||||
|
'include_image': False,
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://growl.server?version=2', {
|
||||||
|
# don't include an image by default
|
||||||
|
'include_image': False,
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
'growl_response': None,
|
||||||
|
}),
|
||||||
|
|
||||||
|
# Priorities
|
||||||
|
('growl://pass@growl.server?priority=low', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://pass@growl.server?priority=moderate', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://pass@growl.server?priority=normal', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://pass@growl.server?priority=high', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://pass@growl.server?priority=emergency', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
|
||||||
|
# Invalid Priorities
|
||||||
|
('growl://pass@growl.server?priority=invalid', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://pass@growl.server?priority=', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
|
||||||
|
# invalid version
|
||||||
|
('growl://growl.server?version=', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://growl.server?version=crap', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
|
||||||
|
# Ports
|
||||||
|
('growl://growl.changeport:2000', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://growl.garbageport:garbage', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
('growl://growl.colon:', {
|
||||||
|
'instance': apprise.plugins.NotifyGrowl,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
# iterate over our dictionary and test it out
|
# iterate over our dictionary and test it out
|
||||||
for (url, meta) in TEST_URLS:
|
for (url, meta) in urls:
|
||||||
|
|
||||||
# Our expected instance
|
# Our expected instance
|
||||||
instance = meta.get('instance', None)
|
instance = meta.get('instance', None)
|
||||||
|
@ -162,54 +322,15 @@ def test_growl_plugin(mock_gntp):
|
||||||
growl_response = meta.get(
|
growl_response = meta.get(
|
||||||
'growl_response', True if response else False)
|
'growl_response', True if response else False)
|
||||||
|
|
||||||
test_growl_notify_exceptions = meta.get(
|
|
||||||
'test_growl_notify_exceptions', False)
|
|
||||||
|
|
||||||
test_growl_register_exceptions = meta.get(
|
|
||||||
'test_growl_register_exceptions', False)
|
|
||||||
|
|
||||||
mock_notifier = mock.Mock()
|
mock_notifier = mock.Mock()
|
||||||
mock_gntp.return_value = mock_notifier
|
mock_gntp.return_value = mock_notifier
|
||||||
|
mock_notifier.notify.side_effect = None
|
||||||
|
|
||||||
test_growl_exceptions = (
|
# Store our response
|
||||||
plugins.gntp.errors.NetworkError(
|
mock_notifier.notify.return_value = growl_response
|
||||||
0, 'gntp.ParseError() not handled'),
|
|
||||||
plugins.gntp.errors.AuthError(
|
|
||||||
0, 'gntp.AuthError() not handled'),
|
|
||||||
plugins.gntp.errors.UnsupportedError(
|
|
||||||
'gntp.UnsupportedError() not handled'),
|
|
||||||
)
|
|
||||||
|
|
||||||
if test_growl_notify_exceptions is True:
|
|
||||||
# Store oure exceptions
|
|
||||||
test_growl_notify_exceptions = test_growl_exceptions
|
|
||||||
|
|
||||||
elif test_growl_register_exceptions is True:
|
|
||||||
# Store oure exceptions
|
|
||||||
test_growl_register_exceptions = test_growl_exceptions
|
|
||||||
|
|
||||||
for exception in test_growl_register_exceptions:
|
|
||||||
mock_notifier.register.side_effect = exception
|
|
||||||
try:
|
|
||||||
obj = Apprise.instantiate(url, suppress_exceptions=False)
|
|
||||||
|
|
||||||
except TypeError:
|
|
||||||
# This is the response we expect
|
|
||||||
assert True
|
|
||||||
|
|
||||||
except Exception:
|
|
||||||
# We can't handle this exception type
|
|
||||||
assert False
|
|
||||||
|
|
||||||
# We're done this part of the test
|
|
||||||
continue
|
|
||||||
|
|
||||||
else:
|
|
||||||
# Store our response
|
|
||||||
mock_notifier.notify.return_value = growl_response
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
obj = Apprise.instantiate(url, suppress_exceptions=False)
|
obj = apprise.Apprise.instantiate(url, suppress_exceptions=False)
|
||||||
|
|
||||||
assert exception is None
|
assert exception is None
|
||||||
|
|
||||||
|
@ -223,7 +344,7 @@ def test_growl_plugin(mock_gntp):
|
||||||
|
|
||||||
assert isinstance(obj, instance) is True
|
assert isinstance(obj, instance) is True
|
||||||
|
|
||||||
if isinstance(obj, plugins.NotifyBase):
|
if isinstance(obj, apprise.plugins.NotifyBase):
|
||||||
# We loaded okay; now lets make sure we can reverse this url
|
# We loaded okay; now lets make sure we can reverse this url
|
||||||
assert isinstance(obj.url(), six.string_types) is True
|
assert isinstance(obj.url(), six.string_types) is True
|
||||||
|
|
||||||
|
@ -233,11 +354,11 @@ def test_growl_plugin(mock_gntp):
|
||||||
|
|
||||||
# Instantiate the exact same object again using the URL from
|
# Instantiate the exact same object again using the URL from
|
||||||
# the one that was already created properly
|
# the one that was already created properly
|
||||||
obj_cmp = Apprise.instantiate(obj.url())
|
obj_cmp = apprise.Apprise.instantiate(obj.url())
|
||||||
|
|
||||||
# Our object should be the same instance as what we had
|
# Our object should be the same instance as what we had
|
||||||
# originally expected above.
|
# originally expected above.
|
||||||
if not isinstance(obj_cmp, plugins.NotifyBase):
|
if not isinstance(obj_cmp, apprise.plugins.NotifyBase):
|
||||||
# Assert messages are hard to trace back with the way
|
# Assert messages are hard to trace back with the way
|
||||||
# these tests work. Just printing before throwing our
|
# these tests work. Just printing before throwing our
|
||||||
# assertion failure makes things easier to debug later on
|
# assertion failure makes things easier to debug later on
|
||||||
|
@ -253,32 +374,10 @@ def test_growl_plugin(mock_gntp):
|
||||||
assert getattr(key, obj) == val
|
assert getattr(key, obj) == val
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if test_growl_notify_exceptions is False:
|
# check that we're as expected
|
||||||
# check that we're as expected
|
assert obj.notify(
|
||||||
assert obj.notify(
|
title='test', body='body',
|
||||||
title='test', body='body',
|
notify_type=apprise.NotifyType.INFO) == response
|
||||||
notify_type=NotifyType.INFO) == response
|
|
||||||
|
|
||||||
else:
|
|
||||||
for exception in test_growl_notify_exceptions:
|
|
||||||
mock_notifier.notify.side_effect = exception
|
|
||||||
try:
|
|
||||||
assert obj.notify(
|
|
||||||
title='test', body='body',
|
|
||||||
notify_type=NotifyType.INFO) is False
|
|
||||||
|
|
||||||
except AssertionError:
|
|
||||||
# Don't mess with these entries
|
|
||||||
raise
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
# We can't handle this exception type
|
|
||||||
print('%s / %s' % (url, str(e)))
|
|
||||||
assert False
|
|
||||||
|
|
||||||
except AssertionError:
|
|
||||||
# Don't mess with these entries
|
|
||||||
raise
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Check that we were expecting this exception to happen
|
# Check that we were expecting this exception to happen
|
||||||
|
|
Loading…
Reference in New Issue