+
+ Growl
+
+ Enable Growl Notifications
+
+
+
Prowl
@@ -1192,6 +1209,26 @@
}
});
+ if ($("#growl").is(":checked"))
+ {
+ $("#growloptions").show();
+ }
+ else
+ {
+ $("#growloptions").hide();
+ }
+
+ $("#growl").click(function(){
+ if ($("#growl").is(":checked"))
+ {
+ $("#growloptions").slideDown();
+ }
+ else
+ {
+ $("#growloptions").slideUp();
+ }
+ });
+
if ($("#prowl").is(":checked"))
{
$("#prowloptions").show();
diff --git a/headphones/__init__.py b/headphones/__init__.py
index 286c59f4..fbf0dddf 100644
--- a/headphones/__init__.py
+++ b/headphones/__init__.py
@@ -221,6 +221,10 @@ ENCODERLOSSLESS = False
ENCODER_MULTICORE = False
ENCODER_MULTICORE_COUNT = 0
DELETE_LOSSLESS_FILES = False
+GROWL_ENABLED = True
+GROWL_HOST = None
+GROWL_PASSWORD = None
+GROWL_ONSNATCH = True
PROWL_ENABLED = True
PROWL_PRIORITY = 1
PROWL_KEYS = None
@@ -341,7 +345,7 @@ def initialize():
NZB_DOWNLOADER, TORRENT_DOWNLOADER, PREFERRED_WORDS, REQUIRED_WORDS, IGNORED_WORDS, LASTFM_USERNAME, \
INTERFACE, FOLDER_PERMISSIONS, FILE_PERMISSIONS, ENCODERFOLDER, ENCODER_PATH, ENCODER, XLDPROFILE, BITRATE, SAMPLINGFREQUENCY, \
MUSIC_ENCODER, ADVANCEDENCODER, ENCODEROUTPUTFORMAT, ENCODERQUALITY, ENCODERVBRCBR, ENCODERLOSSLESS, ENCODER_MULTICORE, ENCODER_MULTICORE_COUNT, DELETE_LOSSLESS_FILES, \
- PROWL_ENABLED, PROWL_PRIORITY, PROWL_KEYS, PROWL_ONSNATCH, PUSHOVER_ENABLED, PUSHOVER_PRIORITY, PUSHOVER_KEYS, PUSHOVER_ONSNATCH, PUSHOVER_APITOKEN, MIRRORLIST, \
+ GROWL_ENABLED, GROWL_HOST, GROWL_PASSWORD, GROWL_ONSNATCH, PROWL_ENABLED, PROWL_PRIORITY, PROWL_KEYS, PROWL_ONSNATCH, PUSHOVER_ENABLED, PUSHOVER_PRIORITY, PUSHOVER_KEYS, PUSHOVER_ONSNATCH, PUSHOVER_APITOKEN, MIRRORLIST, \
TWITTER_ENABLED, TWITTER_ONSNATCH, TWITTER_USERNAME, TWITTER_PASSWORD, TWITTER_PREFIX, \
PUSHBULLET_ENABLED, PUSHBULLET_APIKEY, PUSHBULLET_DEVICEID, PUSHBULLET_ONSNATCH, \
MIRROR, CUSTOMHOST, CUSTOMPORT, CUSTOMSLEEP, HPUSER, HPPASS, XBMC_ENABLED, XBMC_HOST, XBMC_USERNAME, XBMC_PASSWORD, XBMC_UPDATE, \
@@ -368,6 +372,7 @@ def initialize():
CheckSection('Waffles')
CheckSection('Rutracker')
CheckSection('What.cd')
+ CheckSection('Growl')
CheckSection('Prowl')
CheckSection('Pushover')
CheckSection('PushBullet')
@@ -541,6 +546,11 @@ def initialize():
ENCODER_MULTICORE_COUNT = max(0, check_setting_int(CFG, 'General', 'encoder_multicore_count', 0))
DELETE_LOSSLESS_FILES = bool(check_setting_int(CFG, 'General', 'delete_lossless_files', 1))
+ GROWL_ENABLED = bool(check_setting_int(CFG, 'Growl', 'growl_enabled', 0))
+ GROWL_HOST = check_setting_str(CFG, 'Growl', 'growl_host', '')
+ GROWL_PASSWORD = check_setting_str(CFG, 'Growl', 'growl_password', '')
+ GROWL_ONSNATCH = bool(check_setting_int(CFG, 'Growl', 'growl_onsnatch', 0))
+
PROWL_ENABLED = bool(check_setting_int(CFG, 'Prowl', 'prowl_enabled', 0))
PROWL_KEYS = check_setting_str(CFG, 'Prowl', 'prowl_keys', '')
PROWL_ONSNATCH = bool(check_setting_int(CFG, 'Prowl', 'prowl_onsnatch', 0))
@@ -934,6 +944,12 @@ def config_write():
new_config['General']['ignored_words'] = IGNORED_WORDS
new_config['General']['required_words'] = REQUIRED_WORDS
+ new_config['Growl'] = {}
+ new_config['Growl']['growl_enabled'] = int(GROWL_ENABLED)
+ new_config['Growl']['growl_host'] = GROWL_HOST
+ new_config['Growl']['growl_password'] = GROWL_PASSWORD
+ new_config['Growl']['growl_onsnatch'] = int(GROWL_ONSNATCH)
+
new_config['Prowl'] = {}
new_config['Prowl']['prowl_enabled'] = int(PROWL_ENABLED)
new_config['Prowl']['prowl_keys'] = PROWL_KEYS
diff --git a/headphones/notifiers.py b/headphones/notifiers.py
index 8b204019..c43b2541 100644
--- a/headphones/notifiers.py
+++ b/headphones/notifiers.py
@@ -24,6 +24,7 @@ from httplib import HTTPSConnection
from urllib import urlencode
import os.path
import subprocess
+import gntp.notifier
import lib.simplejson as simplejson
from xml.dom import minidom
@@ -35,6 +36,82 @@ except:
import lib.oauth2 as oauth
import lib.pythontwitter as twitter
+class GROWL:
+
+ def __init__(self):
+ self.enabled = headphones.GROWL_ENABLED
+ self.host = headphones.GROWL_HOST
+ self.password = headphones.GROWL_PASSWORD
+
+ def conf(self, options):
+ return cherrypy.config['config'].get('Growl', options)
+
+ def notify(self, message, event):
+ if not self.enabled:
+ return
+
+ # Split host and port
+ if self.host == "":
+ host, port = "localhost", 23053
+ if ":" in self.host:
+ host, port = self.host.split(':', 1)
+ port = int(port)
+ else:
+ host, port = self.host, 23053
+
+ # If password is empty, assume none
+ if self.password == "":
+ password = None
+ else:
+ password = self.password
+
+ # Register notification
+ growl = gntp.notifier.GrowlNotifier(
+ applicationName='Headphones',
+ notifications=['New Event'],
+ defaultNotifications=['New Event'],
+ hostname=host,
+ port=port,
+ password=password
+ )
+
+ try:
+ growl.register()
+ except gntp.notifier.errors.NetworkError:
+ logger.info(u'Growl notification failed: network error')
+ return
+ except gntp.notifier.errors.AuthError:
+ logger.info(u'Growl notification failed: authentication error')
+ return
+
+ # Send it, including an image
+ image_file = os.path.join(str(headphones.PROG_DIR), 'data/images/headphoneslogo.png')
+ image = open(image_file, 'rb').read()
+
+ try:
+ growl.notify(
+ noteType='New Event',
+ title=event,
+ description=message,
+ icon=image
+ )
+ except gntp.notifier.errors.NetworkError:
+ logger.info(u'Growl notification failed: network error')
+ return
+
+ logger.info(u"Growl notifications sent.")
+
+ def updateLibrary(self):
+ #For uniformity reasons not removed
+ return
+
+ def test(self, host, password):
+ self.enabled = True
+ self.host = host
+ self.password = password
+
+ self.notify('ZOMG Lazors Pewpewpew!', 'Test Message')
+
class PROWL:
keys = []
@@ -44,7 +121,6 @@ class PROWL:
self.enabled = headphones.PROWL_ENABLED
self.keys = headphones.PROWL_KEYS
self.priority = headphones.PROWL_PRIORITY
- pass
def conf(self, options):
return cherrypy.config['config'].get('Prowl', options)
diff --git a/headphones/postprocessor.py b/headphones/postprocessor.py
index 0de3a75a..981c7e77 100644
--- a/headphones/postprocessor.py
+++ b/headphones/postprocessor.py
@@ -407,6 +407,12 @@ def doPostProcessing(albumid, albumpath, release, tracks, downloaded_track_list,
logger.info(u'Post-processing for %s - %s complete' % (release['ArtistName'], release['AlbumTitle']))
+ if headphones.GROWL_ENABLED:
+ pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle']
+ logger.info(u"Growl request")
+ growl = notifiers.GROWL()
+ growl.notify(pushmessage,"Download and Postprocessing completed")
+
if headphones.PROWL_ENABLED:
pushmessage = release['ArtistName'] + ' - ' + release['AlbumTitle']
logger.info(u"Prowl request")
diff --git a/headphones/sab.py b/headphones/sab.py
index 45b494a6..2bcbe2d2 100644
--- a/headphones/sab.py
+++ b/headphones/sab.py
@@ -119,6 +119,10 @@ def sendNZB(nzb):
if sabText == "ok":
logger.info(u"NZB sent to SAB successfully")
+ if headphones.GROWL_ENABLED and headphones.GROWL_ONSNATCH:
+ logger.info(u"Sending Growl notification")
+ growl = notifiers.GROWL()
+ growl.notify(nzb.name,"Download started")
if headphones.PROWL_ENABLED and headphones.PROWL_ONSNATCH:
logger.info(u"Sending Prowl notification")
prowl = notifiers.PROWL()
diff --git a/headphones/transmission.py b/headphones/transmission.py
index 55449290..cfb151c6 100644
--- a/headphones/transmission.py
+++ b/headphones/transmission.py
@@ -49,6 +49,10 @@ def addTorrent(link):
retid = False
logger.info(u"Torrent sent to Transmission successfully")
+ if headphones.GROWL_ENABLED and headphones.GROWL_ONSNATCH:
+ logger.info(u"Sending Growl notification")
+ growl = notifiers.GROWL()
+ growl.notify(name,"Download started")
if headphones.PROWL_ENABLED and headphones.PROWL_ONSNATCH:
logger.info(u"Sending Prowl notification")
prowl = notifiers.PROWL()
diff --git a/headphones/webserve.py b/headphones/webserve.py
index 6532c121..2866f452 100644
--- a/headphones/webserve.py
+++ b/headphones/webserve.py
@@ -933,6 +933,10 @@ class WebInterface(object):
"encoder_multicore": checked(headphones.ENCODER_MULTICORE),
"encoder_multicore_count": int(headphones.ENCODER_MULTICORE_COUNT),
"delete_lossless_files": checked(headphones.DELETE_LOSSLESS_FILES),
+ "growl_enabled": checked(headphones.GROWL_ENABLED),
+ "growl_onsnatch": checked(headphones.GROWL_ONSNATCH),
+ "growl_host": headphones.GROWL_HOST,
+ "growl_password": headphones.GROWL_PASSWORD,
"prowl_enabled": checked(headphones.PROWL_ENABLED),
"prowl_onsnatch": checked(headphones.PROWL_ONSNATCH),
"prowl_keys": headphones.PROWL_KEYS,
@@ -1013,7 +1017,7 @@ class WebInterface(object):
destination_dir=None, lossless_destination_dir=None, folder_format=None, file_format=None, file_underscores=0, include_extras=0, single=0, ep=0, compilation=0, soundtrack=0, live=0,
remix=0, spokenword=0, audiobook=0, autowant_upcoming=False, autowant_all=False, keep_torrent_files=False, interface=None, log_dir=None, cache_dir=None, music_encoder=0, encoder=None, xldprofile=None,
bitrate=None, samplingfrequency=None, encoderfolder=None, advancedencoder=None, encoderoutputformat=None, encodervbrcbr=None, encoderquality=None, encoderlossless=0,
- delete_lossless_files=0, prowl_enabled=0, prowl_onsnatch=0, prowl_keys=None, prowl_priority=0, xbmc_enabled=0, xbmc_host=None, xbmc_username=None, xbmc_password=None,
+ delete_lossless_files=0, growl_enabled=0, growl_onsnatch=0, growl_host=None, growl_password=None, prowl_enabled=0, prowl_onsnatch=0, prowl_keys=None, prowl_priority=0, xbmc_enabled=0, xbmc_host=None, xbmc_username=None, xbmc_password=None,
xbmc_update=0, xbmc_notify=0, nma_enabled=False, nma_apikey=None, nma_priority=0, nma_onsnatch=0, pushalot_enabled=False, pushalot_apikey=None, pushalot_onsnatch=0, synoindex_enabled=False,
pushover_enabled=0, pushover_onsnatch=0, pushover_keys=None, pushover_priority=0, pushover_apitoken=None, pushbullet_enabled=0, pushbullet_onsnatch=0, pushbullet_apikey=None, pushbullet_deviceid=None, twitter_enabled=0, twitter_onsnatch=0, mirror=None, customhost=None, customport=None,
customsleep=None, hpuser=None, hppass=None, preferred_bitrate_high_buffer=None, preferred_bitrate_low_buffer=None, preferred_bitrate_allow_lossless=0, cache_sizemb=None,
@@ -1129,6 +1133,10 @@ class WebInterface(object):
headphones.ENCODER_MULTICORE = encoder_multicore
headphones.ENCODER_MULTICORE_COUNT = max(0, int(encoder_multicore_count))
headphones.DELETE_LOSSLESS_FILES = int(delete_lossless_files)
+ headphones.GROWL_ENABLED = growl_enabled
+ headphones.GROWL_ONSNATCH = growl_onsnatch
+ headphones.GROWL_HOST = growl_host
+ headphones.GROWL_PASSWORD = growl_password
headphones.PROWL_ENABLED = prowl_enabled
headphones.PROWL_ONSNATCH = prowl_onsnatch
headphones.PROWL_KEYS = prowl_keys
diff --git a/lib/gntp/LICENSE b/lib/gntp/LICENSE
new file mode 100644
index 00000000..c274a5a2
--- /dev/null
+++ b/lib/gntp/LICENSE
@@ -0,0 +1,20 @@
+Copyright (c) 2013 Paul Traylor
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/lib/gntp/__init__.py b/lib/gntp/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/gntp/cli.py b/lib/gntp/cli.py
new file mode 100644
index 00000000..bc083062
--- /dev/null
+++ b/lib/gntp/cli.py
@@ -0,0 +1,141 @@
+# 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 gntp.notifier import GrowlNotifier
+from gntp.shim import RawConfigParser
+from gntp.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()
diff --git a/lib/gntp/config.py b/lib/gntp/config.py
new file mode 100644
index 00000000..7536bd14
--- /dev/null
+++ b/lib/gntp/config.py
@@ -0,0 +1,77 @@
+# 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
+
+import gntp.notifier
+import gntp.shim
+
+__all__ = [
+ 'mini',
+ 'GrowlNotifier'
+]
+
+logger = logging.getLogger(__name__)
+
+
+class GrowlNotifier(gntp.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 = gntp.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
+ gntp.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')
diff --git a/lib/gntp/core.py b/lib/gntp/core.py
new file mode 100644
index 00000000..ee544d3d
--- /dev/null
+++ b/lib/gntp/core.py
@@ -0,0 +1,511 @@
+# Copyright: 2013 Paul Traylor
+# These sources are released under the terms of the MIT license: see LICENSE
+
+import hashlib
+import re
+import time
+
+import gntp.shim
+import gntp.errors as errors
+
+__all__ = [
+ 'GNTPRegister',
+ 'GNTPNotice',
+ 'GNTPSubscribe',
+ 'GNTPOK',
+ 'GNTPError',
+ 'parse_gntp',
+]
+
+#GNTP/ [:][ :.]
+GNTP_INFO_LINE = re.compile(
+ 'GNTP/(?P\d+\.\d+) (?PREGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)' +
+ ' (?P[A-Z0-9]+(:(?P[A-F0-9]+))?) ?' +
+ '((?P[A-Z0-9]+):(?P[A-F0-9]+).(?P[A-F0-9]+))?\r\n',
+ re.IGNORECASE
+)
+
+GNTP_INFO_LINE_SHORT = re.compile(
+ 'GNTP/(?P\d+\.\d+) (?PREGISTER|NOTIFY|SUBSCRIBE|\-OK|\-ERROR)',
+ re.IGNORECASE
+)
+
+GNTP_HEADER = re.compile('([\w-]+):(.+)')
+
+GNTP_EOL = gntp.shim.b('\r\n')
+GNTP_SEP = gntp.shim.b(': ')
+
+
+class _GNTPBuffer(gntp.shim.StringIO):
+ """GNTP Buffer class"""
+ def writeln(self, value=None):
+ if value:
+ self.write(gntp.shim.b(value))
+ self.write(GNTP_EOL)
+
+ def writeheader(self, key, value):
+ if not isinstance(value, str):
+ value = str(value)
+ self.write(gntp.shim.b(key))
+ self.write(GNTP_SEP)
+ self.write(gntp.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 = gntp.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 = gntp.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 = gntp.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 = gntp.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 = gntp.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 = gntp.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')
diff --git a/lib/gntp/errors.py b/lib/gntp/errors.py
new file mode 100644
index 00000000..c006fd68
--- /dev/null
+++ b/lib/gntp/errors.py
@@ -0,0 +1,25 @@
+# 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"
diff --git a/lib/gntp/notifier.py b/lib/gntp/notifier.py
new file mode 100644
index 00000000..1719ecdf
--- /dev/null
+++ b/lib/gntp/notifier.py
@@ -0,0 +1,265 @@
+# 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 `_
+
+"""
+import logging
+import platform
+import socket
+import sys
+
+from gntp.version import __version__
+import gntp.core
+import gntp.errors as errors
+import gntp.shim
+
+__all__ = [
+ 'mini',
+ 'GrowlNotifier',
+]
+
+logger = logging.getLogger(__name__)
+
+
+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 gntp.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 = gntp.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 = gntp.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 = gntp.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(gntp.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 = gntp.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) == gntp.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')
diff --git a/lib/gntp/shim.py b/lib/gntp/shim.py
new file mode 100644
index 00000000..3a387828
--- /dev/null
+++ b/lib/gntp/shim.py
@@ -0,0 +1,45 @@
+# 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):
+ return s.encode('utf8', 'replace')
+ return s
+
+ def u(s):
+ if isinstance(s, unicode):
+ return s
+ if isinstance(s, int):
+ s = str(s)
+ return unicode(s, "utf8", "replace")
+
+ from StringIO import StringIO
+ from ConfigParser import RawConfigParser
+
+b.__doc__ = "Ensure we have a byte string"
+u.__doc__ = "Ensure we have a unicode string"
diff --git a/lib/gntp/version.py b/lib/gntp/version.py
new file mode 100644
index 00000000..2166aaca
--- /dev/null
+++ b/lib/gntp/version.py
@@ -0,0 +1,4 @@
+# Copyright: 2013 Paul Traylor
+# These sources are released under the terms of the MIT license: see LICENSE
+
+__version__ = '1.0.2'