diff options
-rw-r--r-- | .SRCINFO | 20 | ||||
-rw-r--r-- | PKGBUILD | 27 | ||||
-rwxr-xr-x | pb | 147 | ||||
-rw-r--r-- | pushybullet.py | 1373 | ||||
-rwxr-xr-x | setup.py | 25 |
5 files changed, 1592 insertions, 0 deletions
diff --git a/.SRCINFO b/.SRCINFO new file mode 100644 index 000000000000..a75693438f92 --- /dev/null +++ b/.SRCINFO @@ -0,0 +1,20 @@ +pkgbase = python2-pushybullet + pkgdesc = PushBullet APIv2 python bindings + pkgver = 1.5.4 + pkgrel = 1 + url = http://github.com/kstep/pushybullet + arch = any + license = GPL + depends = python2 + optdepends = python2-websocket-client: read pushes stream in real-time + optdepends = python2-dateutil: parse datetime in string format + optdepends = python2-magic: file type autodetection + source = pushybullet.py + source = pb + source = setup.py + md5sums = 9596e1f55ea766bdd0e110b85bc6767a + md5sums = 654983199a909516b9d643c3c1714190 + md5sums = ddf1a4bf540c4c21044fe8ec50b76ef3 + +pkgname = python2-pushybullet + diff --git a/PKGBUILD b/PKGBUILD new file mode 100644 index 000000000000..d7bd4c67308a --- /dev/null +++ b/PKGBUILD @@ -0,0 +1,27 @@ +# Maintainer: Konstantin Stepanov <me@kstep.me> +pkgname="python2-pushybullet" +pkgver="1.5.4" +pkgrel=1 +pkgdesc="PushBullet APIv2 python bindings" +arch=('any') +url="http://github.com/kstep/pushybullet" +license=('GPL') +depends=('python2') +optdepends=('python2-websocket-client: read pushes stream in real-time' + 'python2-dateutil: parse datetime in string format' + 'python2-magic: file type autodetection') +source=(pushybullet.py pb setup.py) + +build() { + cd "$srcdir" + python2 ./setup.py build +} + +package() { + cd "$srcdir" + python2 ./setup.py install --root="$pkgdir" --skip-build --optimize=1 +} + +md5sums=('9596e1f55ea766bdd0e110b85bc6767a' + '654983199a909516b9d643c3c1714190' + 'ddf1a4bf540c4c21044fe8ec50b76ef3') @@ -0,0 +1,147 @@ +#!/usr/bin/env python2 +# encoding: utf-8 + +from __future__ import print_function + +import argparse +import pushybullet +import time +import sys +import os + +def get_parser(): + apikey = pushybullet.get_apikey_from_config() + parser = argparse.ArgumentParser(description='PushBullet command line client') + parser.add_argument('--apikey', help='API key (get from https://www.pushbullet.com/account)', type=str, + default=apikey, required=not apikey) + parser.add_argument('--target', help='target device identifiers', default=[], action='append') + subparsers = parser.add_subparsers(help='message type', dest='type') + + note_group = subparsers.add_parser('note') + note_group.add_argument('body', help='note body', type=str, default='', nargs='?') + note_group.add_argument('--title', help='note title', default='', type=str) + + link_group = subparsers.add_parser('link') + link_group.add_argument('url', help='link URL', type=str) + link_group.add_argument('--title', help='link title', default='', type=str) + link_group.add_argument('--body', help='link messsage', default='', type=str) + + list_group = subparsers.add_parser('list') + list_group.add_argument('items', help='list item', action='append', nargs='+', metavar='item') + list_group.add_argument('--title', help='list title', default='', type=str) + + file_group = subparsers.add_parser('file') + file_group.add_argument('file', help='file name to push', type=argparse.FileType('rb'), default=sys.stdin, nargs='?') + file_group.add_argument('--name', help='user visible file name', type=str, default='', dest='file_name') + file_group.add_argument('--mime', help='file mime type', type=str, default='', dest='file_type') + file_group.add_argument('--body', help='file message', default='', type=str) + + address_group = subparsers.add_parser('address') + address_group.add_argument('address', help='address', type=str) + address_group.add_argument('--name', help='address name', default='', type=str) + + devices_group = subparsers.add_parser('devices', help='list all devices') + + contacts_group = subparsers.add_parser('contacts', help='list all contacts') + + clients_group = subparsers.add_parser('clients', help='list all clients') + + channels_group = subparsers.add_parser('channels', help='list all channels') + + subscriptions_group = subparsers.add_parser('subscriptions', help='list all subscriptions') + + pushes_group = subparsers.add_parser('pushes', help='list all pushes') + pushes_group.add_argument('--since', help='show pushes since this timestamp', type=str, default='') + pushes_group.add_argument('--with-empty', help='include empty pushes', action='store_false', dest='skip_empty', default=True) + + watch_group = subparsers.add_parser('watch', help='watch for events') + watch_group.add_argument('--with-nop', help='include "nop" events', action='store_false', dest='skip_nop', default=True) + watch_group.add_argument('--with-pushes', help='output arrived pushes', action='store_true', dest='with_pushes', default=False) + watch_group.add_argument('--with-empty', help='include empty pushes', action='store_false', dest='skip_empty', default=True) + + return parser + +def command_devices(api, args): + devices = api.devices() + for device in devices: + print(device.iden, str(device)) + +def command_pushes(api, args): + pushes = api.pushes(since=args['since'], skip_empty=args['skip_empty']) + for push in pushes: + print_push(push) + +def command_contacts(api, args): + contacts = api.contacts() + for contact in contacts: + print(contact.iden, '%s <%s>' % (contact.name, contact.email)) + +def command_watch(api, args): + print('Watching for push events (press <Ctrl-C> to interrupt)...') + + try: + for event in api.stream(skip_nop=args['skip_nop']): + print_event(event) + + if args['with_pushes']: + for push in event.pushes(skip_empty=args['skip_empty']): + print_push(push) + + except KeyboardInterrupt: + print('Watching stopped') + +def command_push(api, args): + devices = args.pop('target') or [api] + print('... preparing push ...') + push = api.make_push(args) + for device in devices: + print('... pushing to %s ...' % device) + push.send(device) + + print('... all done!') + +def command_clients(api, args): + clients = api.clients() + for client in clients: + print(client.iden, str(client)) + +def command_channels(api, args): + channels = api.channels() + for channel in channels: + print(channel.iden, str(channel)) + +def command_subscriptions(api, args): + subscriptions = api.subscriptions() + for subscription in subscriptions: + print(subscription.__dict__) + print(subscription.iden, str(subscription)) + +def print_push(push): + print('%(created)s %(type)s %(iden)s [%(flags)s] %(sender_iden)s <%(sender_email)s> -> %(receiver_iden)s <%(receiver_email)s> %(target_device_iden)s %(push)s' % dict( + created=time.strftime('%b %d %Y %H:%M:%S', time.localtime(getattr(push, 'created', 0))), + type=push.type, iden=push.iden, push=unicode(push), + target_device_iden=getattr(push, 'target_device_iden', '-'), + sender_iden=getattr(push, 'sender_iden', 'N/A'), sender_email=getattr(push, 'sender_email', 'N/A'), + receiver_iden=getattr(push, 'receiver_iden', 'N/A'), receiver_email=getattr(push, 'receiver_email', 'N/A'), + flags=('A' if push.active else '') + ('D' if getattr(push, 'dismissed', False) else ''))) + +def print_event(event): + print('%(time)s %(type)s %(data)s' % dict( + time=time.strftime('%b %d %Y %H:%M:%S', time.localtime(event.time)), + type='tickle' if isinstance(event, pushybullet.TickleEvent) else + 'push' if isinstance(event, pushybullet.PushEvent) else + 'nop' if isinstance(event, pushybullet.NopEvent) else + 'unknown', + data=event.subtype if isinstance(event, pushybullet.TickleEvent) else + event.push.type if isinstance(event, pushybullet.PushEvent) else + '')) + +def main(): + parser = get_parser() + args = vars(parser.parse_args()) + api = pushybullet.PushBullet(args.pop('apikey')) + command = globals().get('command_%s' % args['type'], command_push) + command(api, args) + +if __name__ == '__main__': + main() diff --git a/pushybullet.py b/pushybullet.py new file mode 100644 index 000000000000..5ee976950a09 --- /dev/null +++ b/pushybullet.py @@ -0,0 +1,1373 @@ +# -*- encoding: utf-8 -*- + +from StringIO import StringIO +import os +import datetime +import time +import base64 +import binascii + +import urllib +import urlparse +import httplib +import random + +try: + import simplejson as json +except ImportError: + import json + +class FilelikeGenerator(object): + def __init__(self, gen): + self.__gen = gen + self.__buf = '' + self.__eof = False + + def __popbuf(self, buflen): + self.__buf, res = self.__buf[buflen+1:], self.__buf[0:buflen] + return res + + def read(self, buflen=0): + if buflen > 0: + if len(self.__buf) >= buflen: + return self.__popbuf(buflen) + + if self.__eof: + return None + + while len(self.__buf) < buflen: + try: + self.__buf += self.__gen.next() + except StopIteration: + self.__eof = True + break + + return self.__popbuf(buflen) + + else: + for part in self.__gen: + self.__buf += part + + self.__eof = True + res, self.__buf = self.__buf, '' + return res or None + + def next(self): + value = self.read(8192) + if value is None: + raise StopIteration + return value + + def isatty(self): + return False + + @property + def closed(self): + return False + + def close(self): + pass + + def seek(self, pos, whence=0): + raise NotImplementedError + +def filelike_generator(func): + def wrapper(*args, **kwargs): + return FilelikeGenerator(func(*args, **kwargs)) + return wrapper + +class Session(object): + auth = () + headers = {} + + def get(self, url, params=None, auth=None, headers=None): + return self._request('GET', url, params=params, auth=auth, headers=headers) + + def post(self, url, params=None, data=None, files=None, auth=None, headers=None): + return self._request('POST', url, params=params, data=data, files=files, auth=auth, headers=headers) + + def delete(self, url, params=None, auth=None, headers=None): + return self._request('DELETE', url, params=params, auth=auth, headers=headers) + + def _encode_form_data(self, pairs): + boundary = ''.join(chr(random.choice(xrange(ord('a'), ord('z')))) for _ in xrange(0, 30)) + + body = [] + for name, value in pairs: + if hasattr(value, 'read'): + _body = value.read() + body.append( + 'Content-Type: application/octet-stream\r\n' + 'Content-Disposition: form-data; name="%s"; filename="%s"\r\n' + 'Content-Length: %s\r\n' + '\r\n' + '%s' % ( + urllib.quote(name), + urllib.quote(getattr(value, 'name', None) or "file.txt"), + len(_body), + _body)) + else: + body.append( + 'Content-Type: text/plain\r\n' + 'Content-Disposition: form-data; name="%s"\r\n' + 'Content-Length: %s\r\n' + '\r\n' + '%s' % ( + urllib.quote(name), + len(value), + value)) + + return 'multipart/form-data; boundary="%s"' % boundary, ('--%s\r\n' % boundary) + ('\r\n--%s\r\n' % boundary).join(body) + ('\r\n--%s--\r\n' % boundary) + + class Response(object): + def __init__(self, resp): + self.__resp = resp + + def json(self): + return json.load(self.__resp) + + def raise_for_status(self): + status = self.__resp.status + kind = status // 100 + + if kind in (1, 2, 3): + return + + raise RuntimeError('%s %s' % (status, self.__resp.reason)) + + def _request(self, method, url, params=None, data=None, files=None, auth=None, headers=None): + _url = urlparse.urlparse(url) + + conn = {'http': httplib.HTTPConnection, + 'https': httplib.HTTPSConnection}[_url.scheme](_url.hostname, _url.port) + + if params: + _params = params.copy() + for k in _params.keys(): + if _params[k] is None: + del _params[k] + + _query = urllib.urlencode(_params) + + else: + _query = _url.query + + _headers = self.headers.copy() + _headers['Host'] = _url.hostname + + if files: + content_type, _data = self._encode_form_data(p for n in (data, files) for p in n.iteritems()) + + elif data: + content_type, _data = ('application/x-www-form-urlencoded', + urllib.urlencode(data) if isinstance(data, dict) else str(data)) + + else: + content_type, _data = None, None + + if _data: + _headers['Content-Type'] = content_type + #_headers['Content-Length'] = str(len(_data)) + + if headers: + _headers.update(headers) + + _auth = auth if auth is not None else ( + (_url.username or '', _url.password or '') + if (_url.username is not None or _url.password is not None) else + self.auth) + if _auth: + _headers['Authorization'] = 'Basic %s' % base64.encodestring(':'.join(_auth)).strip() + + conn.request(method, '?'.join((_url.path, _query)), _data, _headers) + + response = conn.getresponse() + return self.Response(response) + +def get_apikey_from_config(): + try: + from ConfigParser import SafeConfigParser as ConfigParser + except ImportError: + from configparser import SafeConfigParser as ConfigParser + + try: + config = ConfigParser() + config.read(os.path.expanduser('~/.config/pushbullet/config.ini')) + return config.get('pushbullet', 'apikey') + except: + return None + +def utf8(s): + return s if isinstance(s, unicode) else unicode(s, 'utf-8') if isinstance(s, str) else unicode(s) + +def parse_since(since): + if not since: + return 0 + + if isinstance(since, (long, int)): + return since + time.time() if since < 0 else since + + if isinstance(since, datetime.date): + return since.strftime('%s') + + if isinstance(since, datetime.timedelta): + return (datetime.datetime.now() - since).strftime('%s') + + try: + since = int(since) + return since + time.time() if since < 0 else since + + except ValueError: + from dateutil.parser import parse + return parse(since).strftime('%s') + + +# Events {{{ +class Event(object): + ''' + Abstract Pushbullet event + ''' + __slots__ = ['api', 'time'] + def __init__(self, api): + self.time = time.time() + self.api = api + + def __repr__(self): + return '<%s @%s>' % (self.__class__.__name__, self.time) + + def pushes(self, skip_empty=False, limit=None): + return xrange(0) # empty generator + + def latest_push_time(self): + try: + push = self.pushes(limit=1).next() + return push.get('modified') or push.get('created') + except (StopIteration, AttributeError): + return None + +class NopEvent(Event): + ''' + Nop event (keep-alive ticks) + ''' + __slots__ = ['api', 'time'] + +class TickleEvent(Event): + ''' + Tickle event (user pushes) + ''' + __slots__ = ['api', 'time', 'since', 'subtype'] + def __init__(self, api, subtype, since): + Event.__init__(self, api) + self.subtype = subtype + self.since = since + + def pushes(self, skip_empty=False, limit=None): + return self.api.pushes(since=self.since, skip_empty=skip_empty, limit=limit) + + def __repr__(self): + return '<%s[%s] @%s>' % (self.__class__.__name__, self.subtype, self.time) + +class PushEvent(Event): + ''' + Push event (device notification mirroring) + ''' + __slots__ = ['api', 'time', 'push'] + def __init__(self, api, push): + Event.__init__(self, api) + self.push = push + + def __repr__(self): + return (u'<%s[%r] @%s>' % (self.__class__.__name__, self.push, self.time)).encode('utf-8') + + def pushes(self, skip_empty=False, limit=None): + yield self.push + +# }}} + +class PushBulletError(Exception): + pass + +class PushBulletObject(object): + ''' + Abstract Pushbullet object for given REST endpoint + ''' + + collection_name = None + + def __init__(self, **data): + self.__dict__.update(data) + + @property + def uri(self): + ''' + Relative REST-object URI + ''' + raise NotImplementedError + + def delete(self): + ''' + Delete object + ''' + self.api.delete(self.uri) + + def bind(self, api): + ''' + Bind object to given API object + + :param PushBullet api: API object to bind to + :returns: self + ''' + assert(isinstance(api, PushBullet)) + self.api = api + return self + + @property + def bound(self): + ''' + True if an object is bound to an API object + ''' + return bool(getattr(self, 'api', None)) + + def reload(self): + self.__dict__.update(self.api.get(self.uri)) + return self + + @classmethod + def iterate(cls, api, skip_inactive=True, since=0, limit=None): + it = api.paged(cls.collection_name, + modified_after=parse_since(since), + limit=limit) + + print(cls) + if skip_inactive: + return (cls(api, **o) for o in it if o.get('active', False)) + else: + return (cls(api, **o) for o in it) + + def get(self, name, default=None): + return getattr(self, name, default) + + def json(self): + return dict(self.__dict__) + + def __contains__(self, name): + return hasattr(self, name) + + def __str__(self): + return unicode(self).encode('utf8') + +class ObjectWithIden(object): + @classmethod + def load(cls, api, iden): + self = cls() + self.bind(api) + self.iden = iden + self.reload() + return self + + def __init__(self, api, iden=None, **data): + self.iden = iden + self.__dict__.update(data) + self.bind(api) + + @property + def uri(self): + return '%s/%s' % (self.collection_name, self.iden) + +class Grant(ObjectWithIden, PushBulletObject): + collection_name = 'grants' + + def __repr__(self): + return (u'<Grant[%s]: %s>' % (self.iden, self.client['name'])).encode('utf-8') + + def __unicode__(self): + return u'grant for %s' % (self.client['name']) + +class Subscription(ObjectWithIden, PushBulletObject): + collection_name = 'subscriptions' + + def __repr__(self): + return (u'<Subscription[%s] %s>' % (self.iden, getattr(self, 'channel', {}).get('tag', 'untagged'))).encode('utf-8') + + def __unicode__(self): + channel = getattr(self, 'channel', {}) + return u'%s (%s)' % ( + channel.get('name', 'Unnamed'), + channel.get('tag', 'untagged')) + + def channel(self): + return ChannelInfo(self.api, **getattr(self, 'channel', {})) + + def create(self, channel_tag): + if self.iden: + raise PushBulletError('subscription already exists') + + if isinstance(channel_tag, (ChannelInfo, Channel)): + channel_tag = channel_tag.tag + + self.__dict__.update(self.api.post('subscriptions', channel_tag=str(channel_tag))) + + return self + +class ChannelInfo(ObjectWithIden, PushBulletObject): + collection_name = 'channel-info' + + @classmethod + def load_by_tag(cls, api, tag): + return cls(api, **api.get('/channel-info', tag=utf8(tag))) + + def __repr__(self): + return (u'<ChannelInfo[%s]: %s (%s)>' % (self.iden, + getattr(self, 'name', 'Unnamed'), + getattr(self, 'tag', 'untagged'))).encode('utf-8') + + def __unicode__(self): + return u'%s (%s)' % ( + getattr(self, 'name', 'Unnamed'), + getattr(self, 'tag', 'untagged')) + + def subscribe(self): + return Subscription(self.api, None).create(self.tag) + +# Push targets {{{ + +class PushTarget(PushBulletObject): + ''' + Abstract push target object + ''' + @property + def ident(self): + ''' + Interface property with parameters to identify given push target + :rtype: dict + ''' + raise NotImplementedError + + def push(self, push=None, **pushargs): + ''' + Push a message to the push target + + Either `push` or `pushargs` must be given. + If both are present, `pushargs` are ignored. + + :param Push push: a push object + :param dict pushargs: parameters to construct push object + ''' + if not isinstance(push, Push): + push = self.api.make_push(pushargs, push) + + push.send(self) + return push + +class Channel(PushTarget, ObjectWithIden): + ''' + Channel to push to + ''' + collection_name = 'channels' + + def __repr__(self): + return (u'<Channel[%s]: %s (%s)>' % (self.iden, + getattr(self, 'name', 'Unnamed'), + getattr(self, 'tag', 'untagged'))).encode('utf-8') + + def __unicode__(self): + return u'%s (%s)' % ( + getattr(self, 'name', 'Unnamed'), + getattr(self, 'tag', 'untagged')) + + @property + def ident(self): + return {'channel_tag': self.tag} + + def create(self): + if self.iden: + raise PushBulletError('channel already exists') + + self.__dict__.update(self.api.post('clients', + tag=self.tag, + name=getattr(self, 'name', None), + description=getattr(self, 'description', None), + feed_url=getattr(self, 'feed_url', None), + feed_filters=getattr(self, 'feed_filters', None))) + + return self + + def update(self): + if not self.iden: + raise PushBulletError('channel does not exist yet') + + self.__dict__.update(self.api.post(self.uri, + name=getattr(self, 'name', None), + description=getattr(self, 'description', None), + feed_url=getattr(self, 'feed_url', None), + feed_filters=getattr(self, 'feed_filters', None))) + + return self + + def subscribe(self): + return Subscription(self.api, None).create(self.tag) + +class Client(PushTarget, ObjectWithIden): + ''' + Current user's OAuth client + + By pushing to it you push to all users, who granted access to the client. + ''' + collection_name = 'clients' + + def __repr__(self): + return (u'<Client[%s]: %s>' % (self.iden, getattr(self, 'name', 'Unnamed'))).encode('utf-8') + + def __unicode__(self): + return getattr(self, 'name', 'Unnamed') + + @property + def ident(self): + return {'client_iden': self.iden} + +class Contact(ObjectWithIden, PushTarget): + ''' + Contact to push to + ''' + collection_name = 'contacts' + + def __repr__(self): + return (u'<Contact[%s]: %s <%s>>' % (self.iden, + getattr(self, 'name', 'Unnamed'), + getattr(self, 'email', None) or getattr(self, 'email_normalized'))).encode('utf-8') + + def __unicode__(self): + return u'%s <%s>' % (self.name, self.email) + + @property + def ident(self): + return {'email': self.email_normalized} + + def create(self): + if self.iden: + raise PushBulletError('contact already exists') + + self.__dict__.update(self.api.post('contacts', name=self.name, email=self.email)) + return self + + def update(self): + if not self.iden: + raise PushBulletError('contact does not exist yet') + + self.__dict__.update(self.api.post(self.uri, name=self.name)) + return self + + def rename(self, newname): + self.name = newname + return self.update() + +class Device(ObjectWithIden, PushTarget): + ''' + Device to push to + ''' + collection_name = 'devices' + + def __repr__(self): + return (u'<Device[%s]: %s>' % (self.iden, + getattr(self, 'nickname', None) or + getattr(self, 'model', None) or + 'Unnamed')).encode('utf-8') + + def __unicode__(self): + return (getattr(self, 'nickname', None) or + getattr(self, 'model', None) or + self.iden) + + @property + def ident(self): + return {'device_iden': self.iden} + + def create(self): + if self.iden: + raise PushBulletError('device already exists') + + self.__dict__.update(self.api.post('devices', nickname=self.nickname, type=getattr(self, 'type', 'stream'))) + return self + + def update(self): + if not self.iden: + raise PushBulletError('device does not exist yet') + + self.__dict__.update(self.api.post(self.uri, nickname=self.nickname)) + return self + + def rename(self, newname): + self.nickname = newname + return self.update() + +class PhoneNumber(PushTarget): + ''' + Phone number to push SMS to + ''' + device = None + number = None + + def __init__(self, device, number): + assert isinstance(device, Device) + self.device = device + self.number = str(number) + + @property + def ident(self): + return { + 'type': 'messaging_extension_reply', + 'package_name': 'com.pushbullet.android', + 'conversation_iden': self.number, + 'target_device_iden': self.device.iden, + 'source_user_iden': self.api.me.iden + } + + def push(self, push=None, **pushargs): + api = self.device.api + if not isinstance(push, Push): + push = api.make_push(pushargs, push) + + data = self.ident + data['message'] = push.body + api.post("ephemerals", type="push", push=data) + return push + + def __repr__(self): + return (u'<PhoneNumber[%s] device=%s>' % (self.number, self.device)).encode('utf-8') + + def __unicode__(self): + return self.number + +class User(PushTarget): + ''' + User profile + ''' + def __init__(self, api, **data): + self.__dict__.update(data) + self.bind(api) + + @classmethod + def load(cls, api): + return cls(api, **api.get('users/me')) + + def __repr__(self): + return (u'<User[%s]: %s <%s>>' % (self.iden, + getattr(self, 'name', 'Unnamed'), + getattr(self, 'email', None) or getattr(self, 'email_normalized'))).encode('utf-8') + + @property + def ident(self): + return {} + + @property + def uri(self): + return 'users/me' + + def update(self): + self.__dict__.update(self.api.post(self.uri, preferences=getattr(self, 'preferences', {}))) + return self + + def set_prefs(self, **prefs): + try: + self.preferences.update(prefs) + except AttributeError: + self.prefereces = prefs + + return self.update() + +# }}} + +# Pushes {{{ + +class Push(PushBulletObject): + ''' + Abstract push object + ''' + type = None + collection_name = 'pushes' + + @property + def uri(self): + return "pushes/%s" % self.iden + + def decode(self): + try: + self.modified = datetime.datetime.fromtimestamp(self.modified) + except AttributeError: + pass + + try: + self.created = datetime.datetime.fromtimestamp(self.created) + except AttributeError: + pass + + def send(self, target=None): + ''' + Send the push to some target + + If target is None (or omitted) or a string, the push must be bound to some API. + By default the push will be sent to API object (i.e. all user devices). + + If target is a string, it must be a device iden to push to. + + If you use anything except for Device, Contact or PushBullet object as a target + (i.e. a PushTarget object), you must make sure a push is bound to PushBullet + object before by calling `push.bind(api)` method. + Push is already bound if you fetched it with `api.pushes()` call + or sent it before. + + :param target: push target + :type target: PushTarget|str|None + ''' + if not isinstance(target, PushTarget): + target = self.api.make_target(target) + + self.bind(target.api) + + data = self.data + data.update(target.ident) + data['type'] = self.type + + result = self.api.post('pushes', **data) + self.__dict__.update(result) + + def resend(self): + ''' + Try to send the push to the same target again (e.g. as a part of error handling logic) + ''' + if not hasattr(self, 'target_device_iden'): + raise PushBulletError('push was not sent yet') + + self.send(self.target_device_iden) + + def update(self): + self.__dict__.update(self.api.post(self.uri, dissmissed=getattr(self, 'dismissed', False))) + return self + + def dismiss(self): + ''' + Dismiss a push + ''' + if getattr(self, 'dismissed', False): + return # don't dismiss twice + + self.dismissed = True + return self.update() + + @property + def data(self): + ''' + Push data necessary to send push to a target + + :rtype: dict + ''' + raise NotImplementedError + + @property + def target_device(self): + ''' + Get target device object + ''' + iden = self.get('target_device_iden') + return Device(self.api, iden) if iden else None + + @property + def source_device(self): + ''' + Get source device object + ''' + iden = self.get('source_device_iden') + return Device(self.api, iden) if iden else None + + def __eq__(self, other): + return isinstance(other, Push) and self.iden == other.iden + + def __repr__(self): + return (u'<%s[%s]: %s>' % (self.__class__.__name__, getattr(self, 'iden', None), unicode(self))).encode('utf-8') + + def __unicode__(self): + return u'%s push' % getattr(self, 'type', 'general') + + +class NotePush(Push): + ''' + Note push + ''' + type = 'note' + def __init__(self, body='', title='', **data): + ''' + A note push constructor + + Notes has both body and title parameters optional, but at least one of them + must be defined for the push to be useful. The `title` argument defaults to "Note" + by PushBullet service, so I choose to require at least `body` argument. + If you really don't need body (you should define `title` then, or you will end up + with empty note, which usually has no sense), set it to empty string `''`. + + :type body: str + :type title: str + ''' + self.title, self.body = utf8(title), utf8(body) + Push.__init__(self, **data) + + @property + def data(self): + return {'title': self.title, 'body': self.body} + + def __unicode__(self): + return self.title + +class LinkPush(Push): + ''' + Link push + ''' + type = 'link' + def __init__(self, url, title='', body='', **data): + ''' + A link push constructor + + URL is the only required argument, otherwise the push is actually useless. + You can of cause set it to empty string (`''`), but what is the use of it? + + :type url: str + :type title: str + :type body: str + ''' + self.title, self.url, self.body = utf8(title), utf8(url), utf8(body) + Push.__init__(self, **data) + + @property + def data(self): + return {'title': self.title, 'url': self.url, 'body': self.body} + + def __unicode__(self): + return self.url + +class AddressPush(Push): + ''' + Address push + ''' + type = 'address' + def __init__(self, address, name='', **data): + ''' + An address push constructor + + Address argument is the only required argument here. + The push actually has no sense without it, doesn't it? + + :type address: str + :type name: str + ''' + self.name, self.address = utf8(name), utf8(address) + Push.__init__(self, **data) + + @property + def data(self): + return {'name': self.name, 'address': self.address} + + def __unicode__(self): + return u'%s (%s)' % (self.name, self.address) + +class ListPush(Push): + ''' + List push + ''' + type = 'list' + def __init__(self, items, title='', **data): + ''' + A list push constructor + + Items argument is the only required one, and it should be a list + of strings. Of cause you can send empty list (`[]`), but what is the use of it? + + :type items: list of str + :type title: str + ''' + self.title, self.items = utf8(title), map(utf8, items) + Push.__init__(self, **data) + + @property + def data(self): + return {'title': self.title, 'items': self.items} + + def __unicode__(self): + return u'%s (%d)' % (self.title, len(self.items)) + +class FilePush(Push): + ''' + File push + ''' + type = 'file' + def __init__(self, file=None, file_name=None, file_type=None, body='', **data): + ''' + A file push constructor + + You must specify at least `file` argument in order to be able to push some new file + + The `file` argument is optional only for internal usage, like if a file push fetched + by `api.pushes()` method call, in which case file to push is undefined, but `file_url` + is present to download the file. If you then resend such push, file designated by `file_url` + will be sent, not any new file set by you. You can use this knowledge to try and + set `file_url` directly on `FilePush` object without setting `file` argument on your + own risk, but bear in mind it's an internal implementation detail, so please make sure you + a) understand what you are doing, b) don't abuse the feature. + + If you specify `file` only, it must be either a file-like object, a file-handler opened for read, + a buffer (for in-memory files), an openable object (the one with `open([mode])` method) + or a string with absolute file path. + You will see basename of the file (the part of path after final slash). + + If you specify both `file` and `file_name`, a push receiver will see `file_name` value + as a file name, not the actual file name. You can use it for example to send some + system stream without user-friendly name, like `sys.stdin`. + + The `file_type` argument is optional and must be a string in MIME-type format (e.g. `text/plain`). + If you omit it, file type will be deteremined by magic library by file's content, and if + autodetection will fail, file type will default to `application/octet-stream`. + The autodetection reads first 1024 bytes of file content and then resets file's seek cursor + to the beginning, so it won't work for non-seekable streams, so if you are about to push + something like `sys.stdin`, make sure you set `file_type` manually. + + :param file: file to push + :type file: str, file, buffer, int, Path or any file-like or openable object + :param str file_name: file name to push (will be visible to reciever) + :param str file_type: file's MIME type (will be determined by file's content if omitted) + :param str body: optional message to accompany file + ''' + assert(file or file_name) + self.file, self.file_name, self.file_type = file, utf8(file_name), utf8(file_type) + if not self.file: + self.file = self.file_name + + self.file_url = None + self.body = utf8(body) + Push.__init__(self, **data) + + def send(self, target=None): + if not isinstance(target, PushTarget): + target = self.api.make_target(target) + + if not self.file_url: # file not uploaded yet + fh = (self.file if hasattr(self.file, 'read') else # file-like object + self.file.open('rb') if hasattr(self.file, 'open') else # openable object + os.fdopen(self.file, 'rb') if isinstance(self.file, int) else # file descriptor + StringIO(self.file) if isinstance(self.file, buffer) else # in-memory file + open(self.file, 'rb')) # file name + + try: + file_name = utf8(self.file_name) if self.file_name else os.path.basename(fh.name) + file_type = utf8(self.file_type) if self.file_type else self.guess_type(fh) + req = target.api.get('upload-request', file_name=file_name, file_type=file_type) + target.api.upload(req['upload_url'], data=req['data'], file=fh) + self.file_name, self.file_type, self.file_url = req['file_name'], req['file_type'], req['file_url'] + + finally: + fh.close() + + Push.send(self, target) + + def guess_type(self, file): + try: + import magic + guesser = magic.open(magic.MIME_TYPE) + guesser.load() + mime_type = guesser.buffer(file.read(1024)) + file.seek(0) + return mime_type or 'application/octet-stream' + + except: + return 'application/octet-stream' + + @property + def data(self): + return {'file_name': self.file_name, 'file_type': self.file_type, 'file_url': self.file_url, 'body': self.body} + + def __unicode__(self): + return self.file_name + +class MirrorPush(Push): + ''' + Mirror push (internal usage only) + ''' + type = 'mirror' + + def decode(self): + super(MirrorPush, self).decode() + + try: + self.icon = base64.decodestring(self.icon) + except (AttributeError, binascii.Error): + pass + + def send(self, target): + raise NotImplementedError + +class DismissalPush(Push): + ''' + Dismissal push (internal usage only) + ''' + type = 'dismissal' + + def send(self, target): + raise NotImplementedError + +# }}} + +# Main API class {{{ + +def cached_list_method(cls): + cache_key = '_%s' % cls.collection_name + def wrapper(self, reset_cache=False): + if reset_cache or getattr(self, cache_key, None) is None: + setattr(self, cache_key, list(cls.iterate(self))) + return getattr(self, cache_key) + return wrapper + +def iterator_method(cls): + def iterator(self, skip_inactive=False, since=0, limit=None): + return cls.iterate(self, skip_inactive, since, limit) + return iterator + +class PushBullet(PushTarget): + ''' + Main API class for PushBullet + ''' + + API_URL = 'https://api.pushbullet.com/v2/%s' + + def __init__(self, apikey): + ''' + Initialize API object (get API key from https://www.pushbullet.com/account) + + :param str apikey: API key (get at https://www.pushbullet.com/account) + ''' + self.apikey = apikey + self.sess = Session() + self.sess.auth = (apikey, '') + + def get_type_by_args(self, args, arg=None): + return args.get('type') or ('url' if 'url' in args else + 'list' if 'items' in args else + 'address' if 'address' in args else + 'file' if 'file' in args or 'file_name' in args else + self.get_type_by_class(arg) if arg else + 'note') + + def get_type_by_class(self, arg): + if isinstance(arg, (file, buffer)): + return 'file' + + # any iteratable (except for strings) is a list push + if hasattr(arg, '__iter__') and not isinstance(arg, (str, unicode)): + return 'list' + + # special case: looks like url, therefore it is an link push + if utf8(arg).startswith(('http://', 'https://', 'ftp://', 'ftps://', 'mailto:')): + return 'link' + + # default is a note push + return 'note' + + def make_push(self, pushargs, pusharg=None): + ''' + Factory to create a push object out of raw data in dictionary + + The `pushargs` dict should contain `type` element to determine + push type. If you omit it, push type will be autodefined by presence of + other elements, defaulting to `note`. + + Other `pushargs` elements depend on push type: + + * for `note`: `title` and `body`, + * for `list`: `title` and `items`, + * for `file`: `file`, `file_name`, `file_type` and `body`, + * for `link`: `title` and `url`, + * for `address`: `name` and `address`. + + See correspondent push classes docs for details of these arguments. + + :param dict pushargs: a dict of parameters to compose a push object + ''' + # a set of arguments in a dictionary + pushcls = { + 'note': NotePush, + 'list': ListPush, + 'link': LinkPush, + 'file': FilePush, + 'address': AddressPush, + 'mirror': MirrorPush, + 'dismissal': DismissalPush, + }.get(self.get_type_by_args(pushargs, pusharg), Push) + push = pushcls(pusharg, **pushargs) if pusharg else pushcls(**pushargs) + + return push.bind(self) + + def delete(self, _uri): + ''' + Helper method for DELETE requests to API + ''' + self.sess.delete(self.API_URL % _uri).raise_for_status() + + def post(self, _uri, **data): + ''' + Helper method for POST requests to API + ''' + response = self.sess.post(self.API_URL % _uri, data=json.dumps(data), + headers={'Content-Type': 'application/json'}) + response.raise_for_status() + + result = response.json() + + if 'error' in result: + raise PushBulletError(result['message']) + + return result + + def get(self, _uri, **params): + ''' + Helper method for GET requests to API + ''' + response = self.sess.get(self.API_URL % _uri, params=params) + response.raise_for_status() + + result = response.json() + + if 'error' in result: + raise PushBulletError(result['message']) + + return result + + def upload(self, _uri, data, **files): + ''' + Helper method to upload a file to given URL + ''' + response = self.sess.post(_uri, data=data, files=files, auth=()).raise_for_status() + + def paged(self, _uri, **params): + page = self.get(_uri, **params) + + while True: + for item in page[_uri]: + yield item + + if not page.get('cursor'): + break + + page = self.get(_uri, cursor=page['cursor']) + + def subscribe(self, channel_tag): + return Subscription(self, None).create(channel_tag) + + def create_device(self, nickname, type='stream'): + ''' + Create new (a stream) device with given nickname + + :param str nickname: device's name + :param str type: device's type + :rtype: Device + ''' + return Device(self, None, nickname=nickname, type=type).create() + + def create_contact(self, name, email): + ''' + Create a new contact with given name and email + + :param str name: contact's name + :param str email: contact's email + :rtype: Contact + ''' + return Contact(self, None, name=name, email=email).create() + + iter_contacts = iterator_method(Contact) + iter_devices = iterator_method(Device) + iter_grants = iterator_method(Grant) + iter_clients = iterator_method(Client) + iter_channels = iterator_method(Channel) + iter_subscriptions = iterator_method(Subscription) + + contacts = cached_list_method(Contact) + devices = cached_list_method(Device) + grants = cached_list_method(Grant) + clients = cached_list_method(Client) + channels = cached_list_method(Channel) + subscriptions = cached_list_method(Subscription) + + def pushes(self, since=0, skip_empty=True, limit=None): + ''' + Generator fetches and yields all pushes since given timestamp + + The `since` argument, which defines time limit in the past to get + pushes from, accepts almost any sensible date/time object. + + If it is an integer (int or long) and positive, it is expected + to be correct unix timestamp (number of seconds since 1/1/1970 00:00:00 UTC). + If it is an integer and negative, it is a number of seconds in the past + (e.g. use `-86400` to fetch pushes for the last day). + If it is a date or datetime object, it is, well, a date/time =). + If it is a timedelta object, it is a time-span in the past + (so `timedelta(days=7)` means "pushes for the last week"). + If it is a string, it is parsed with dateutil.parser.parse() for datetime object. + + :param since: minimal time for pushes to fetch + :type since: int|long|date|datetime|timedelta + :param bool skip_empty: skip empty (inactive, removed) pushes, default is True + :param int limit: limit number of items per page + :rtype: generator + ''' + it = self.paged(Push.collection_name, + modified_after=parse_since(since), + limit=limit) + + if skip_empty: + return (self.make_push(o) for o in it if bool(o.get('type', None))) + else: + return (self.make_push(o) for o in it) + + def __getitem__(self, device_iden): + ''' + Find and return device object by device iden or device name + + At first search is done by device iden field, and if it's not found, + search is repeated by device name. A device name is either device nickname, + model or iden, whichever is defined for any given device object. + + So you can get your Chrome device object with `api["Chrome"]` call. + + Throws `KeyError` if no device is found. + + :param str device_iden: a device iden + ''' + try: + return next(d for d in self.devices() if d.iden == device_iden) + except StopIteration: + try: + return next(d for d in self.devices() if utf8(d) == device_iden) + except StopIteration: + raise KeyError(device_iden) + + _me = None + def me(self, reset_cache=False): + ''' + Get current user information + ''' + if not reset_cache and self._me: + return self._me + + self._me = User.load(self) + return self._me + + def make_target(self, target): + if target is None: + return self + + if isinstance(target, PushTarget): + return target + + target = utf8(target) + return (Device(self, target) if '@' not in target else + Contact(self, None, email_normalized=target)) + + def push(self, push=None, target=None, **pushargs): + ''' + Send push to a target (to all devices by default) + + If you omit `target` argument, the push will be sent to all user devices. + If `target` is anything but `PushTarget` object, it will be cast to string + and will be taken as a device iden to push to. + + Only one of `push` or `pushargs` arguments must be given at a time. + If `push` is omitted, new push will be constructed from `pushargs` arguments, + if both `push` and `pushargs` are given, `pushargs` is ignored. + + :param Push push: a push object ot push + :param target: a push target to push to + :type target: str|PushTarget|None + :param dict pushargs: push arguments + :rtype: Push + :returns: push just sent + ''' + if not isinstance(push, Push): + push = self.make_push(pushargs, push) + + push.bind(self).send(target) + return push + + def bind(self, obj): + ''' + Bind given object to the API + + :type obj: PushBulletObject + :returns: obj + ''' + assert(isinstance(obj, PushBulletObject)) + return obj.bind(self) + + @property + def ident(self): + ''' + For `PushTarget` interface compliance + ''' + return {} + + @property + def api(self): + ''' + For `PushTarget` interface compliance only + ''' + return self + + def stream(self, skip_nop=True, use_server_time=False, throttle=1): + ''' + Generator to listen for events on websocket and yield them + + The method requires `websocket` library. It will run an infinite event loop, which + fetches events from websocket and provides them as an Event objects, e.g.:: + + for ev in api.stream(): + print(ev) + + To be able to run any other code at the same time, consider running the loop + in some other (background) thread. + + :param bool skip_nop: skip "nop" events (used as keep-alive heartbeats only), default is True + :param bool use_server_time: use server time to track last push to fetch (requires additional request on event), default is False + :rtype: generator + ''' + from websocket import create_connection + conn = create_connection('wss://stream.pushbullet.com/websocket/%s' % self.apikey) + last_ts = ((self.latest_push_time() or time.time()) if use_server_time else time.time()) + throttle + + while True: + event = json.loads(conn.recv()) + evtype = event['type'] + if skip_nop and evtype == 'nop': + continue + + event = (NopEvent(self) if evtype == 'nop' else + TickleEvent(self, event['subtype'], since=last_ts) if evtype == 'tickle' else + PushEvent(self, self.make_push(event['push'])) if evtype == 'push' else + None) + + last_ts = ((event.latest_push_time() or time.time()) if use_server_time else time.time()) + throttle + + if event: + yield event + + def latest_push_time(self): + try: + push = self.pushes(limit=1).next() + return push.get('modified') or push.get('created') + except StopIteration: + return None + + def __unicode__(self): + return u'<PushBullet>' + +# }}} + +#import yaml +#with open('/usr/local/etc/pushbullet.yml', 'rb') as f: +# config = yaml.safe_load(f) +# +#pb = PushBullet(config['apikey']) + diff --git a/setup.py b/setup.py new file mode 100755 index 000000000000..ebe1b90a685b --- /dev/null +++ b/setup.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python2 + +from distutils.core import setup + +setup(name='PushyBullet', + version='1.5.4', + description='PushBullet APIv2 python bindings', + author='Konstantin Stepanov', + author_email='me@kstep.me', + url='http://github.com/kstep/pushybullet/', + py_modules=['pushybullet'], + install_requires=[ + #'requests>=2.3.0', + #'websocket-client>=0.12.0', + #'dateutil>=2.2', + #'magic>=5.18', + ], + scripts=['pb'], + classifiers=[ + 'Topic :: Software Development :: Libraries', + 'Topic :: Utilities', + 'Topic :: Internet', + 'Development Status :: 4 - Beta', + 'Environment :: Console', + ]) |