diff --git a/README.markdown b/README.markdown index ac42b5f..e9bbd38 100644 --- a/README.markdown +++ b/README.markdown @@ -5,7 +5,7 @@ Open Photo API / Python Library ---------------------------------------- ### Installation -python setup.py install + python setup.py install ---------------------------------------- @@ -13,11 +13,27 @@ python setup.py install ### How to use the library To use the library you need to first ``import openphoto``, then instantiate an instance of the class and start making calls. - + +You can use the library in one of two ways: + * Direct GET/POST calls to the server + * Access via Python classes/methods + + +### Direct GET/POST: + from openphoto import OpenPhoto client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret) - resp = client.get('/photos/list.json') - resp = client.post('/photo/62/update.json', {'tags': 'tag1,tag2'}) + resp = client.get("/photos/list.json") + resp = client.post("/photo/62/update.json", tags=["tag1", "tag2"]) + + +### Python classes/methods + + from openphoto import OpenPhoto + client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret) + photos = client.photos_list() + photos[0].update(tags=["tag1", "tag2"]) + print photos[0].tags ---------------------------------------- diff --git a/openphoto/__init__.py b/openphoto/__init__.py index c190760..3481837 100644 --- a/openphoto/__init__.py +++ b/openphoto/__init__.py @@ -1,50 +1,8 @@ -import oauth2 as oauth -import urlparse -import urllib -import httplib2 -import types +from openphoto_http import OpenPhotoHttp, OpenPhotoError, OpenPhotoDuplicateError +from api_photo import ApiPhoto +from api_tag import ApiTag +from api_album import ApiAlbum - -class OpenPhoto(object): +class OpenPhoto(OpenPhotoHttp, ApiPhoto, ApiTag, ApiAlbum): """ Client library for OpenPhoto """ - - def __init__(self, host, consumer_key='', consumer_secret='', - token='', token_secret=''): - self.host = host - self.consumer_key = consumer_key - self.consumer_secret = consumer_secret - self.token = token - self.token_secret = token_secret - - def get(self, endpoint, params={}): - url = urlparse.urlunparse(('http', self.host, endpoint, '', - urllib.urlencode(params), '')) - if self.consumer_key: - consumer = oauth.Consumer(self.consumer_key, self.consumer_secret) - token = oauth.Token(self.token, self.token_secret) - - client = oauth.Client(consumer, token) - - else: - client = httplib2.Http() - - headers, content = client.request(url, "GET") - return content - - def post(self, endpoint, params={}): - url = urlparse.urlunparse(('http', self.host, endpoint, '', '', '')) - - if self.consumer_key: - consumer = oauth.Consumer(self.consumer_key, self.consumer_secret) - token = oauth.Token(self.token, self.token_secret) - - # ensure utf-8 encoding for all values. - params = dict([(k, v.encode('utf-8') - if type(v) is types.UnicodeType else v) - for (k, v) in params.items()]) - - client = oauth.Client(consumer, token) - body = urllib.urlencode(params) - headers, content = client.request(url, "POST", body) - - return content + pass diff --git a/openphoto/api_album.py b/openphoto/api_album.py new file mode 100644 index 0000000..e2ad812 --- /dev/null +++ b/openphoto/api_album.py @@ -0,0 +1,33 @@ +from openphoto_http import OpenPhotoHttp, OpenPhotoError +from objects import Album + +class ApiAlbum(OpenPhotoHttp): + def album_create(self, name, **kwds): + """ Create a new album and return it""" + result = self.post("/album/create.json", name=name, **kwds)["result"] + return Album(self, result) + + def album_delete(self, album_id, **kwds): + """ Delete an album """ + album = Album(self, {"id": album_id}) + album.delete(**kwds) + + def album_form(self, album_id, **kwds): + raise NotImplementedError() + + def album_add_photos(self, album_id, photo_ids, **kwds): + raise NotImplementedError() + + def album_remove_photos(self, album_id, photo_ids, **kwds): + raise NotImplementedError() + + def albums_list(self, **kwds): + """ Return a list of Album objects """ + results = self.get("/albums/list.json", **kwds)["result"] + return [Album(self, album) for album in results] + + def album_update(self, album_id, **kwds): + """ Update an album """ + album = Album(self, {"id": album_id}) + album.update(**kwds) + # Don't return the album, since the API doesn't give us the modified album diff --git a/openphoto/api_photo.py b/openphoto/api_photo.py new file mode 100644 index 0000000..03d1145 --- /dev/null +++ b/openphoto/api_photo.py @@ -0,0 +1,81 @@ +import base64 + +from openphoto_http import OpenPhotoHttp, OpenPhotoError +from objects import Photo + +class ApiPhoto(OpenPhotoHttp): + def photo_delete(self, photo_id, **kwds): + """ Delete a photo """ + photo = Photo(self, {"id": photo_id}) + photo.delete(**kwds) + + def photo_edit(self, photo_id, **kwds): + """ Returns an HTML form to edit a photo """ + photo = Photo(self, {"id": photo_id}) + return photo.edit(**kwds) + + def photo_replace(self, photo_id, photo_file, **kwds): + raise NotImplementedError() + + def photo_replace_encoded(self, photo_id, photo_file, **kwds): + raise NotImplementedError() + + def photo_update(self, photo_id, **kwds): + """ + Update a photo with the specified parameters. + Returns the updated photo object + """ + photo = Photo(self, {"id": photo_id}) + photo.update(**kwds) + return photo + + def photo_view(self, photo_id, **kwds): + """ + Used to view the photo at a particular size. + Returns the requested photo object + """ + photo = Photo(self, {"id": photo_id}) + photo.view(**kwds) + return photo + + def photos_list(self, **kwds): + """ Returns a list of Photo objects """ + photos = self.get("/photos/list.json", **kwds)["result"] + photos = self._result_to_list(photos) + return [Photo(self, photo) for photo in photos] + + def photos_update(self, photo_ids, **kwds): + """ Updates a list of photos """ + if not self._openphoto.post("/photos/update.json" % photo_ids, + **kwds)["result"]: + raise OpenPhotoError("Update response returned False") + + def photos_delete(self, photo_ids, **kwds): + """ Deletes a list of photos """ + if not self._openphoto.post("/photos/delete.json" % photo_ids, + **kwds)["result"]: + raise OpenPhotoError("Delete response returned False") + + def photo_upload(self, photo_file, **kwds): + raise NotImplementedError("Use photo_upload_encoded instead.") + + def photo_upload_encoded(self, photo_file, **kwds): + """ Base64-encodes and uploads the specified file """ + encoded_photo = base64.b64encode(open(photo_file, "rb").read()) + result = self.post("/photo/upload.json", photo=encoded_photo, + **kwds)["result"] + return Photo(self, result) + + def photo_dynamic_url(self, photo_id, **kwds): + raise NotImplementedError() + + def photo_next_previous(self, photo_id, **kwds): + """ + Returns a dict containing the next and previous photo objects, + given a photo in the middle. + """ + photo = Photo(self, {"id": photo_id}) + return photo.next_previous(**kwds) + + def photo_transform(self, photo_id, **kwds): + raise NotImplementedError() diff --git a/openphoto/api_tag.py b/openphoto/api_tag.py new file mode 100644 index 0000000..c7c4a7f --- /dev/null +++ b/openphoto/api_tag.py @@ -0,0 +1,25 @@ +from openphoto_http import OpenPhotoHttp, OpenPhotoError +from objects import Tag + +class ApiTag(OpenPhotoHttp): + def tag_create(self, tag_id, **kwds): + """ Create a new tag and return it """ + result = self.post("/tag/create.json", tag=tag_id, **kwds)["result"] + return Tag(self, result) + + def tag_delete(self, tag_id, **kwds): + """ Delete a tag """ + tag = Tag(self, {"id": tag_id}) + tag.delete(**kwds) + + def tag_update(self, tag_id, **kwds): + """ Update a tag """ + tag = Tag(self, {"id": tag_id}) + tag.update(**kwds) + return tag + + def tags_list(self, **kwds): + """ Returns a list of Tag objects """ + results = self.get("/tags/list.json", **kwds)["result"] + return [Tag(self, tag) for tag in results] + diff --git a/openphoto/main.py b/openphoto/main.py index f8df7ad..f3c64ae 100644 --- a/openphoto/main.py +++ b/openphoto/main.py @@ -6,9 +6,9 @@ import urllib from optparse import OptionParser try: - import simplejson as json -except: import json +except ImportError: + import simplejson as json from openphoto import OpenPhoto @@ -45,16 +45,16 @@ def main(args=sys.argv[1:]): client = OpenPhoto(options.host, consumer_key, consumer_secret, token, token_secret) if options.method == "GET": - result = client.get(options.endpoint, params) + result = client.get_raw(options.endpoint, **params) else: - result = client.post(options.endpoint, params) + result = client.post_raw(options.endpoint, **params) if options.verbose: print "==========\nMethod: %s\nHost: %s\nEndpoint: %s" % (options.method, options.host, options.endpoint) if len( params ) > 0: - print "Fields:" - for kv in params.iteritems(): - print " %s=%s" % kv + print "Fields:" + for kv in params.iteritems(): + print " %s=%s" % kv print "==========\n" if options.pretty: diff --git a/openphoto/objects.py b/openphoto/objects.py new file mode 100644 index 0000000..2af570f --- /dev/null +++ b/openphoto/objects.py @@ -0,0 +1,147 @@ +from openphoto_http import OpenPhotoError, NotImplementedError + +class OpenPhotoObject: + """ Base object supporting the storage of custom fields as attributes """ + def __init__(self, openphoto, json_dict): + self._openphoto = openphoto + self._json_dict = json_dict + self._set_fields(json_dict) + + def _set_fields(self, json_dict): + """ Set this object's attributes specified in json_dict """ + for key, value in json_dict.items(): + if key.startswith("_"): + raise ValueError("Illegal attribute: %s" % key) + setattr(self, key, value) + + def _replace_fields(self, json_dict): + """ + Delete this object's attributes, and replace with + those in json_dict. + """ + for key in self._json_dict.keys(): + delattr(self, key) + self._json_dict = json_dict + self._set_fields(json_dict) + + def __repr__(self): + if hasattr(self, "name"): + return "<%s name='%s'>" % (self.__class__, self.name) + elif hasattr(self, "id"): + return "<%s id='%s'>" % (self.__class__, self.id) + else: + return "<%s>" % (self.__class__) + + def get_fields(self): + """ Returns this object's attributes """ + return self._json_dict + + +class Photo(OpenPhotoObject): + def delete(self, **kwds): + """ Delete this photo """ + self._openphoto.post("/photo/%s/delete.json" % self.id, **kwds) + self._replace_fields({}) + + def edit(self, **kwds): + """ Returns an HTML form to edit the photo """ + result = self._openphoto.get("/photo/%s/edit.json" % self.id, + **kwds)["result"] + return result["markup"] + + def replace(self, photo_file, **kwds): + raise NotImplementedError() + + def replace_encoded(self, encoded_photo, **kwds): + raise NotImplementedError() + + def update(self, **kwds): + """ Update this photo with the specified parameters """ + new_dict = self._openphoto.post("/photo/%s/update.json" % self.id, + **kwds)["result"] + self._replace_fields(new_dict) + + def view(self, **kwds): + """ + Used to view the photo at a particular size. + Updates the photo's fields with the response. + """ + new_dict = self._openphoto.get("/photo/%s/view.json" % self.id, + **kwds)["result"] + self._replace_fields(new_dict) + + def dynamic_url(self, **kwds): + raise NotImplementedError() + + def next_previous(self, **kwds): + """ Returns a dict containing the next and previous photo objects """ + result = self._openphoto.get("/photo/%s/nextprevious.json" % self.id, + **kwds)["result"] + value = {} + if "next" in result: + value["next"] = Photo(self._openphoto, result["next"]) + if "previous" in result: + value["previous"] = Photo(self._openphoto, result["previous"]) + return value + + def transform(self, **kwds): + raise NotImplementedError() + + +class Tag(OpenPhotoObject): + def delete(self, **kwds): + """ Delete this tag """ + self._openphoto.post("/tag/%s/delete.json" % self.id, **kwds) + self._replace_fields({}) + + def update(self, **kwds): + """ Update this tag with the specified parameters """ + new_dict = self._openphoto.post("/tag/%s/update.json" % self.id, + **kwds)["result"] + self._replace_fields(new_dict) + + +class Album(OpenPhotoObject): + def __init__(self, openphoto, json_dict): + OpenPhotoObject.__init__(self, openphoto, json_dict) + # Update the cover attribute with a photo object + if hasattr(self, "cover") and self.cover is not None: + self.cover = Photo(openphoto, self.cover) + + def delete(self, **kwds): + """ Delete this album """ + self._openphoto.post("/album/%s/delete.json" % self.id, **kwds) + self._replace_fields({}) + + def form(self, **kwds): + raise NotImplementedError() + + def add_photos(self, **kwds): + raise NotImplementedError() + + def remove_photos(self, **kwds): + raise NotImplementedError() + + def update(self, **kwds): + """ Update this album with the specified parameters """ + self._openphoto.post("/album/%s/update.json" % self.id, + **kwds)["result"] + # Since the API doesn't give us the modified album, we need to + # update our fields based on the kwds that were sent + self._set_fields(kwds) + + + def view(self, **kwds): + """ + Requests the full contents of the album. + Updates the album's fields with the response. + """ + result = self._openphoto.get("/album/%s/view.json" % self.id, + **kwds)["result"] + # Update the cover attribute with a photo object + if result["cover"] is not None: + result["cover"] = Photo(self._openphoto, result["cover"]) + # Update the photo list with photo objects + for i, photo in enumerate(result["photos"]): + result["photos"][i] = Photo(self._openphoto, result["photos"][i]) + self._replace_fields(result) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py new file mode 100644 index 0000000..d06085d --- /dev/null +++ b/openphoto/openphoto_http.py @@ -0,0 +1,135 @@ +import oauth2 as oauth +import urlparse +import urllib +import httplib2 +try: + import json +except ImportError: + import simplejson as json + +class OpenPhotoError(Exception): + """ Indicates that an OpenPhoto operation failed """ + pass + +class OpenPhotoDuplicateError(OpenPhotoError): + """ Indicates that an upload operation failed due to a duplicate photo """ + pass + +class NotImplementedError(OpenPhotoError): + """ Indicates that the API function has not yet been coded - please help! """ + pass + +DUPLICATE_RESPONSE = {"code": 409, + "message": "This photo already exists"} + +class OpenPhotoHttp: + """ Base class to handle HTTP requests to an OpenPhoto server """ + def __init__(self, host, consumer_key='', consumer_secret='', + token='', token_secret=''): + self._host = host + self._consumer_key = consumer_key + self._consumer_secret = consumer_secret + self._token = token + self._token_secret = token_secret + + def get(self, endpoint, **params): + """ + Performs an HTTP GET from the specified endpoint (API path), + passing parameters if given. + Returns the decoded JSON dictionary, and + raises exceptions if an error code is received. + """ + response = json.loads(self.get_raw(endpoint, **params)) + self._process_response(response) + return response + + def post(self, endpoint, **params): + """ + Performs an HTTP POST to the specified endpoint (API path), + passing parameters if given. + Returns the decoded JSON dictionary, and + raises exceptions if an error code is received. + """ + response = json.loads(self.post_raw(endpoint, **params)) + self._process_response(response) + return response + + def get_raw(self, endpoint, **params): + """ + Performs an HTTP GET from the specified endpoint (API path), + passing parameters if given. + Returns the raw HTTP content string. + """ + params = self._process_params(params) + url = urlparse.urlunparse(('http', self._host, endpoint, '', + urllib.urlencode(params), '')) + if self._consumer_key: + consumer = oauth.Consumer(self._consumer_key, self._consumer_secret) + token = oauth.Token(self._token, self._token_secret) + client = oauth.Client(consumer, token) + else: + client = httplib2.Http() + + _, content = client.request(url, "GET") + return content + + def post_raw(self, endpoint, **params): + """ + Performs an HTTP POST to the specified endpoint (API path), + passing parameters if given. + Returns the raw HTTP content string. + """ + params = self._process_params(params) + url = urlparse.urlunparse(('http', self._host, endpoint, '', '', '')) + + if not self._consumer_key: + raise OpenPhotoError("Cannot issue POST without OAuth tokens") + + consumer = oauth.Consumer(self._consumer_key, self._consumer_secret) + token = oauth.Token(self._token, self._token_secret) + + client = oauth.Client(consumer, token) + body = urllib.urlencode(params) + _, content = client.request(url, "POST", body) + return content + + @staticmethod + def _process_params(params): + """ Converts Unicode/lists/booleans inside HTTP parameters """ + processed_params = {} + for key, value in params.items(): + # Use UTF-8 encoding + if isinstance(value, unicode): + value = value.encode('utf-8') + # Handle lists + if isinstance(value, list): + value = ",".join(value) + # Handle booleans + if isinstance(value, bool): + value = 1 if value else 0 + processed_params[key] = value + return processed_params + + @staticmethod + def _process_response(response): + """ Raises an exception if an invalid response code is received """ + if response["code"] >= 200 and response["code"] < 300: + return + + error_message = "Code %d: %s" % (response["code"], + response["message"]) + + # Special case for a duplicate photo error + if (response["code"] == DUPLICATE_RESPONSE["code"] and + DUPLICATE_RESPONSE["message"] in response["message"]): + raise OpenPhotoDuplicateError(error_message) + + raise OpenPhotoError(error_message) + + @staticmethod + def _result_to_list(result): + """ Handle the case where the result contains no items """ + if result[0]["totalRows"] == 0: + return [] + else: + return result diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 88b5589..9dd0687 --- a/setup.py +++ b/setup.py @@ -1,3 +1,5 @@ +#!/usr/bin/env python + requires = ['oauth2', 'httplib2'] try: import json @@ -17,7 +19,7 @@ except ImportError: 'requires': requires} setup(name='openphoto', - version='0.1', + version='0.2', description='Client library for the openphoto project', author='James Walker', author_email='walkah@walkah.net',