diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e5c9569 --- /dev/null +++ b/LICENSE @@ -0,0 +1,20 @@ +Copyright (c) 2013 Leandigo (www.leandigo.com) + +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/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..9c8317c --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,2 @@ +include LICENSE +include README.rst \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..1042f44 --- /dev/null +++ b/README.rst @@ -0,0 +1,220 @@ +pyoneall - OneAll API Wrapper +============================= + +**OneAll** (|oneall|_) provides web-applications with a unified API for **20+ social networks**. +**pyoneall** provides developers with OneAll accounts a simple interface with the OneAll API for Python-based web-applications. + +Disclaimer +---------- +*This package is new, and so far has been tested in a development of a small number of projects.* +*Please be sure to test all edge-cases where this package is used with your application!* + +Implementation Overview +----------------------- +OneAll API documentation is available at |onealldoc|_. However, in order to use pyoneall with your application, it's +enough to read the docs for the Connection API: `Connection API Documentation`_. + +So far, we have tested pyoneall within Flask and Django apps. To use OneAll as a Django authentication backend, +please check out our ``django_oneall`` project, which relies on this package. + +pyoneall defines the ``OneAll`` class, which is the API client. As of now, it has the following methods: + +:``connections()``: Get a list of social connections to the site +:``connection()``: Get detailed connection data including user details +:``users()``: Get a list of users that have connected with the site +:``user()``: Get detailed user data +:``user_contacts()``: Get a list of user's contacts +:``publish()``: Publish a message using user's social network account + +As pyoneall wraps a REST API which returns JSON objects, the objects returned by the methods behave in a somewhat +JavaScript-like manner. This means that in addition to the ``dict``-style ``object['key']`` notation, you can also +use ``object.key``. + +Also, arrays nested in the JSON responses, are represented by a class that defines a ``by_*()`` grouping and searching +method in an addition to the ``list`` methods it inherits from. + +For more information on these classes, check out ``help(pyoneall.base.OADict)`` and ``help(pyoneall.base.OAList)``. + +Example +------- + +Authentication +~~~~~~~~~~~~~~ +Access to the OneAll API requires authentication. Obtain your API credentials following the procedure described at +`Authentication Documentation`_. + +Create an instance of the OneAll client:: + + from pyoneall import OneAll + + oa = OneAll( + site_name='NAME OF YOUR ONEALL SITE', + public_key='PUBLIC KEY OF YOUR SITE', + private_key='PRIVATE KEY OF YOUR SITE' + ) + +The Connection API +~~~~~~~~~~~~~~~~~~ +Fetching connections lists +************************** +:: + + connections = oa.connections() + +``connections`` now contains the "connections" portion of the result of the API call, as described in +``_. +Full response data (for debugging and whatnot) is in ``connections.response``. + +OneAll uses pagination for calls which contain many entries. Each call returns a page up to 500 entries. When the +``OneAll.connections()`` method is executed without arguments, only the first page is loaded. You the access the +pagination information in ``connections.pagination``. + +In order to load a custom range of pages, you can do something like:: + + connections = oa.connections(first_page=3, last_page=6) + +Or, if you want to load all pages, use:: + + connections = oa.connections(fetch_all=True) + +Of course, this will result in multiple API calls. + +The connections list itself is in ``connections.entries``:: + + >>> connections.entries + [{u'callback_uri': u'http://www.example.com/connect/', + u'connection_token': u'cf2fffc7-34dc-484e-95cd-13f8ab838e22', + u'date_creation': u'Sun, 23 Jun 2013 14:12:43 +0200', + u'status': u'succeeded'}, + {u'callback_uri': u'http://m.example.com/connect/', + u'connection_token': u'4276bd23-3605-4679-acd2-963148c477cc', + u'date_creation': u'Sun, 23 Jun 2013 14:13:20 +0200', + u'status': u'succeeded'}, + {u'callback_uri': u'http://www.example.com/connect/', + u'connection_token': u'58ad2a04-ed1e-4799-a3ca-2b26651e35a0', + u'date_creation': u'Sun, 23 Jun 2013 14:18:00 +0200', + u'status': u'succeeded'}, + {u'callback_uri': u'http://m.example.com/connect/', + u'connection_token': u'e5231790-c6dc-4ce8-9922-792a2aebbba2', + u'date_creation': u'Sun, 23 Jun 2013 14:18:11 +0200', + u'status': u'succeeded'}, + {u'callback_uri': u'http://www.example.com/connect/', + u'connection_token': u'f82ad1e5-113f-46a2-b1c5-2a57a6002401', + u'date_creation': u'Sun, 23 Jun 2013 14:21:14 +0200', + u'status': u'succeeded'}] + +In the example above, you can see that some connections were made with the callback of the desktop website +(``http://www.example.com/connect/``), and some were made with the mobile webapp (``http://m.example.com/connect/``). +We can get an object grouped by the "callback_uri" using:: + + >>> connections.entries.by_callback_uri() + {u'http://www.example.com/connect/': [ + {u'callback_uri': u'http://www.example.com/connect/', + u'connection_token': u'cf2fffc7-34dc-484e-95cd-13f8ab838e22', + u'date_creation': u'Sun, 23 Jun 2013 14:12:43 +0200', + u'status': u'succeeded'}, + {u'callback_uri': u'http://www.example.com/connect/', + u'connection_token': u'58ad2a04-ed1e-4799-a3ca-2b26651e35a0', + u'date_creation': u'Sun, 23 Jun 2013 14:18:00 +0200', + u'status': u'succeeded'}], + {u'callback_uri': u'http://www.example.com/connect/', + u'connection_token': u'f82ad1e5-113f-46a2-b1c5-2a57a6002401', + u'date_creation': u'Sun, 23 Jun 2013 14:21:14 +0200', + u'status': u'succeeded'}, + u'http://m.example.com/connect/': [ + {u'callback_uri': u'http://m.example.com/connect/', + u'connection_token': u'4276bd23-3605-4679-acd2-963148c477cc', + u'date_creation': u'Sun, 23 Jun 2013 14:13:20 +0200', + u'status': u'succeeded'}, + {u'callback_uri': u'http://m.example.com/connect/', + u'connection_token': u'e5231790-c6dc-4ce8-9922-792a2aebbba2', + u'date_creation': u'Sun, 23 Jun 2013 14:18:11 +0200', + u'status': u'succeeded'}]} + +Or get a list of connections with a specific "callback_uri":: + + >>> connections.entries.by_callback_uri('http://m.example.com/connect/') + [{u'callback_uri': u'http://m.example.com/connect/', + u'connection_token': u'4276bd23-3605-4679-acd2-963148c477cc', + u'date_creation': u'Sun, 23 Jun 2013 14:13:20 +0200', + u'status': u'succeeded'}, + {u'callback_uri': u'http://m.example.com/connect/', + u'connection_token': u'e5231790-c6dc-4ce8-9922-792a2aebbba2', + u'date_creation': u'Sun, 23 Jun 2013 14:18:11 +0200', + u'status': u'succeeded'}] + +Reading connection details +************************** +In order to get the **user_token** and the user's social identity you can pass a **connection_token** to the +``connection()`` method of the ``OneAll`` instance:: + + some_connection = oa.connection('e5231790-c6dc-4ce8-9922-792a2aebbba2') + +Or, alternatively you can fetch the connection details through the ``connection()`` method of an entry in the list +of connections:: + + some_connection = connections.entries[3].connection() + +``some_connection`` will now contain the "connection" portion of the response described in the API documentation for +`Read Connection Details`_, most importantly ``some_connection.user`` and ``some_connection.user.user_token`` + +The User API +~~~~~~~~~~~~ +Fetching user list +****************** +``OneAll.users()`` behaves the same way ``OneAll.connections()`` does, arguments and all. This is due to the similarity +of the List Users and the List Connections API, in terms of pagination and entries structure. +:: + + users = oa.users() + +Now, you can access ``users.entries``, or even access detailed user data with ``users.entries[4].user()``. + +Reading user details +******************** +Read user details using:: + + user_token = some_connection.user.user_token + some_user = oa.user(user_token) + +``some_user`` will contain the "user" portion of the response detailed at +``_. + +Reading user's contacts +*********************** +You can get the user's contacts (depending on the social network) with:: + + contacts = some_user.contacts() + +or, with:: + + contacts = oa.user_contacts(user_token) + +Publishing content on user's behalf +*********************************** +First, you need to format a message as described at ``_. +Afterwards, publish it using ``publish()``:: + + message = { + 'request': { + 'message': { + 'parts': { + 'text': { + 'body': 'Hello World!' }}}}} + + oa.publish(user_token, message) + +License +------- +Copyright (c) 2013, Leandigo (|leandigo|_) +Released under the MIT License. See the LICENSE file for details. + +.. |leandigo| replace:: www.leandigo.com +.. _leandigo: http://www.leandigo.com +.. |oneall| replace:: http://www.oneall.com +.. _oneall: http://www.oneall.com +.. |onealldoc| replace:: http://docs.oneall.com +.. _onealldoc: http://docs.oneall.com +.. _Connection API Documentation: http://docs.oneall.com/api/resources/connections/ +.. _Authentication Documentation: http://docs.oneall.com/api/basic/authentication/ +.. _Read Connection Details: http://docs.oneall.com/api/resources/connections/read-connection-details/ \ No newline at end of file diff --git a/pyoneall/__init__.py b/pyoneall/__init__.py new file mode 100644 index 0000000..b459761 --- /dev/null +++ b/pyoneall/__init__.py @@ -0,0 +1 @@ +from connection import OneAll \ No newline at end of file diff --git a/pyoneall/base.py b/pyoneall/base.py new file mode 100644 index 0000000..52fb2ef --- /dev/null +++ b/pyoneall/base.py @@ -0,0 +1,82 @@ +class OAList(list): + """ + A List of representing a JSON array with nested objects. + """ + each = None + + def __init__(self, init_list=None): + """ + A constructor which receives an iterable, and recursively converts all nested list and dict objects to + OAList and OADict respectively. It also creates by_* methods allowing to perform grouping and search + on OADict objects within the list. + If subclass has the `each` attribute set to a class, all objects in list will be initialized as instances + of that class. + + :param iterable init_list: Values to initiate list with. + """ + if self.each: + super(OAList, self).__init__([self.each(**item) for item in init_list]) + else: + super(OAList, self).__init__([_convert_base(item) for item in init_list]) + + self._dict_items = [item for item in self if isinstance(item, OADict)] + + for key in set().union(*[item.keys() for item in self._dict_items]): + setattr(self, 'by_%s' % key, self._by_attr(key)) + + def _by_attr(self, attr): + """ + Create a custom by_attr function for grouping and searching + """ + def by_attr(query=None, first=False): + if query: + ret = [] + for item in self._dict_items: + if item.get(attr) == query: + if first: + return item + ret.append(item) + return ret + + else: + ret = {} + [ret.setdefault(item.get(attr), []).append(item) for item in self._dict_items + if not (isinstance(item.get(attr), OADict) or isinstance(item.get(attr), OAList))] + return ret + return by_attr + + def drop_nulls(self): + while self.count(None): + self.remove(None) + [item.drop_nulls() for item in self if isinstance(item, OADict) or isinstance(item, OAList)] + +class OADict(dict): + """ + A JavaScript-object-like representing a JSON object with nested objects + """ + ignored = ['response', 'oneall'] + + def __init__(self, *args, **kwargs): + super(OADict, self).__init__( + *args, + **dict((key, _convert_base(value)) for key, value in kwargs.iteritems()) + ) + [setattr(self, key, value) for key, value in self.iteritems()] + self._populate() + + def __setattr__(self, key, value): + super(OADict, self).__setattr__(key, value) + if key not in self.ignored: super(OADict, self).__setitem__(key, value) + + def __setitem__(self, key, value): + super(OADict, self).__setitem__(key, value) + super(OADict, self).__setattr__(key, value) + + def _populate(self): + pass + +def _convert_base(item): + return { + dict : lambda: OADict(**item), + list : lambda: OAList(item), + }.get(type(item), lambda: item)() diff --git a/pyoneall/classes.py b/pyoneall/classes.py new file mode 100644 index 0000000..b4282df --- /dev/null +++ b/pyoneall/classes.py @@ -0,0 +1,81 @@ +from base import OAList, OADict + +class Users(OADict): + """ + Represents a /users/ OneAll API call + """ + class UsersEntries(OAList): + """ + Represents the `entries` attribute + """ + class UsersEntry(OADict): + """ + Represents each object within `entries` + """ + pass + + def user(self): + """ + Returns full user data for user_token in entry + """ + if self.oneall: + return self.oneall.user(self.user_token) + + each = UsersEntry + + @property + def entries(self): + return getattr(self, '_entries', []) + + @entries.setter + def entries(self, value): + self._entries = Users.UsersEntries(value) + +class Connections(OADict): + """ + Represents the /connections/ OneAll API call + """ + + class ConnectionsEntries(OAList): + """ + Represents the `entries` attribute + """ + + class ConnectionsEntry(OADict): + """ + Represents each object within `entries` + """ + + def connection(self): + """ + Returns full connection data for connection_token in entry + """ + if self.oneall: + return self.oneall.connection(self.connection_token) + + each = ConnectionsEntry + + @property + def entries(self): + return getattr(self, '_entries', []) + + @entries.setter + def entries(self, value): + self._entries = Connections.ConnectionsEntries(value) + +class Connection(OADict): + """ + A OneAll Connection + """ + pass + +class User(OADict): + """ + A OneAll User + """ + def contacts(self): + """ + Retrieve user's contacts + """ + if self.oneall: + return self.oneall.user_contacts(self.user_token) \ No newline at end of file diff --git a/pyoneall/connection.py b/pyoneall/connection.py new file mode 100644 index 0000000..9eedfb3 --- /dev/null +++ b/pyoneall/connection.py @@ -0,0 +1,156 @@ +from urllib2 import Request, urlopen +from base64 import encodestring +from json import dumps, loads +from base import OADict +from classes import Users, Connections, Connection, User + +class OneAll(): + """ + A worker for the OneAll REST API. + """ + DEFAULT_API_DOMAIN = 'https://{site_name}.api.oneall.com' + FORMAT__JSON = 'json' + + bindings = {} + + def __init__(self, site_name, public_key, private_key, base_url=None): + """ + :param str site_name: The name of the OneAll site + :param str public_key: API public key for the site + :param str private_key: API private key for the site + :param str base_url: An alternate format for the API URL + """ + self.base_url = base_url if base_url else OneAll.DEFAULT_API_DOMAIN.format(site_name=site_name) + self.public_key = public_key + self.private_key = private_key + + def _exec(self, action, params={}, post_params=None): + """ + Execute an API action + + :param str action: The action to be performed. Translated to REST call + :param str params: Additional GET parameters for action + :post_params: POST parameters for action + :returns dict: The JSON result of the call in a dictionary format + """ + request_url = '%s/%s.%s' % (self.base_url, action, OneAll.FORMAT__JSON) + for ix, (param, value) in enumerate(params.iteritems()): + request_url += "%s%s=%s" % (('?' if ix == 0 else '&'), param, value) + req = Request(request_url, dumps(post_params) if post_params else None, {'Content-Type': 'application/json'}) + auth = encodestring('%s:%s' % (self.public_key, self.private_key)).replace('\n', '') + req.add_header('Authorization', 'Basic %s' % auth) + return loads(urlopen(req).read()) + + def _paginated(self, action, data, page_number=1, last_page=1, fetch_all=False, rtype=OADict): + """ + Wrapper for paginated API calls. Constructs a response object consisting of one or more pages for paginated + calls such as /users/ or /connections/. Returned object will have the ``pagination`` attribute equaling the + the ``pagination`` value of the last page that was loaded. + + :param str action: The action to be performed. + :param str data: The data attribute that holds the response payload + :param int page_number: The first page number to load + :param int last_page: The last page number to load + :param bool fetch_all: Whether to fetch all records or not + :param type rtype: The return type of the of the method + :returns OADict: The API call result + """ + oa_object = rtype() + while page_number <= last_page or fetch_all: + response = OADict(**self._exec(action, {'page' : page_number})).response + page = getattr(response.result.data, data) + oa_object.count = getattr(oa_object, 'count', 0) + getattr(page, 'count', 0) + oa_object.entries = getattr(oa_object, 'entries', []) + getattr(page, 'entries', []) + oa_object.pagination = page.pagination + oa_object.response = response + page_number += 1 + if page.pagination.current_page == page.pagination.total_pages: + break + return oa_object + + def users(self, page_number=1, last_page=1, fetch_all=False): + """ + Get users + + :param int page_number: The first page number to load + :param int last_page: The last page number to load + :param bool fetch_all: Whether to fetch all records or not + :returns Users: The users objects + """ + users = self._paginated('users', 'users', page_number, last_page, fetch_all, Users) + users.oneall = self + [setattr(entry, 'oneall', self) for entry in users.entries] + return users + + def user(self, user_token): + """ + Get a user by user token + + :param str user_token: The user token + :returns User: The user object + """ + response = OADict(**self._exec('users/%s' % (user_token))).response + user = User(**response.result.data.user) + user.response = response + user.oneall = self + return user + + def user_contacts(self, user_token): + """ + Get user's contacts by user token + + :param str user_token: The user token + :returns OADict: User's contacts object + """ + response = OADict(**self._exec('users/%s/contacts' % (user_token))).response + user_contacts = OADict(**response.result.data.identities) + user_contacts.response = response + return user_contacts + + def connections(self, page_number=1, last_page=1, fetch_all=False): + """ + Get connections + + :param int page_number: The first page number to load + :param int last_page: The last page number to load + :param bool fetch_all: Whether to fetch all records or not + :returns Users: The connections + """ + connections = self._paginated('connections', 'connections', page_number, last_page, fetch_all, rtype=Connections) + connections.oneall = self + [setattr(entry, 'oneall', self) for entry in connections.entries] + return connections + + def connection(self, connection_token): + """ + Get connection details by connection token + + :param str connection_token: The connection token + :returns Connection: The requested connection + """ + response = OADict(**self._exec('connection/%s' % (connection_token))).response + connection = Connection(**response.result.data) + connection.response = response + return connection + + def user_contacts(self, user_token): + """ + Get user's contacts + + :param str user_token: The user_token of the user whose contacts are to be fetched + :returns OADict: The user's contacts + """ + response = OADict(**self._exec('users/%s/contacts' % (user_token))).response + contacts = OADict(**response.result.data.identities) + contacts.response = response + return contacts + + def publish(self, user_token, post_params): + """ + Publish a message on behalf of the user + + :param str user_token: The user token + :param dict post_params: The message in the format described in OneAll documentation + :returns OADict: The API response + """ + return OADict(**self._exec('users/%s/publish' % user_token, post_params=post_params)) diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..05c2e3e --- /dev/null +++ b/setup.py @@ -0,0 +1,13 @@ +import os +from distutils.core import setup +README = open(os.path.join(os.path.dirname(__file__), 'README.rst')).read() +setup(name='pyoneall', + version='0.1', + packages=['pyoneall'], + license='MIT License, see LICENSE file', + description='OneAll API wrapper (http://www.oneall.com). Provides unified API for 20+ social networks', + long_description=README, + url='http://www.leandigo.com/', + author='Michael Greenberg / Leandigo', + author_email='michael@leandigo.com' +) \ No newline at end of file