From b418cf7e78de5ce3021952af9017af1f2d0ecf49 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Tue, 28 Aug 2012 18:59:33 +0100 Subject: [PATCH 01/13] Extended API to add pythonic classes/methods. See the updated README.markdown for examples --- README.markdown | 24 +++++- openphoto/__init__.py | 54 ++----------- openphoto/api_album.py | 33 ++++++++ openphoto/api_photo.py | 81 ++++++++++++++++++++ openphoto/api_tag.py | 25 ++++++ openphoto/main.py | 14 ++-- openphoto/objects.py | 147 ++++++++++++++++++++++++++++++++++++ openphoto/openphoto_http.py | 135 +++++++++++++++++++++++++++++++++ setup.py | 4 +- 9 files changed, 457 insertions(+), 60 deletions(-) create mode 100644 openphoto/api_album.py create mode 100644 openphoto/api_photo.py create mode 100644 openphoto/api_tag.py create mode 100644 openphoto/objects.py create mode 100644 openphoto/openphoto_http.py mode change 100644 => 100755 setup.py 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', From cbbf5d56c718fce3ac198444fa44fd5fc045543e Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Tue, 28 Aug 2012 21:03:08 +0100 Subject: [PATCH 02/13] Combine get/post variants into a single set of methods with a process_response parameter. Remember the most recent HTTP request and response to help with debugging. --- openphoto/main.py | 4 +-- openphoto/openphoto_http.py | 63 ++++++++++++++++++++----------------- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/openphoto/main.py b/openphoto/main.py index f3c64ae..e561da5 100644 --- a/openphoto/main.py +++ b/openphoto/main.py @@ -45,9 +45,9 @@ def main(args=sys.argv[1:]): client = OpenPhoto(options.host, consumer_key, consumer_secret, token, token_secret) if options.method == "GET": - result = client.get_raw(options.endpoint, **params) + result = client.get(options.endpoint, process_response=False, **params) else: - result = client.post_raw(options.endpoint, **params) + result = client.post(options.endpoint, process_response=False, **params) if options.verbose: print "==========\nMethod: %s\nHost: %s\nEndpoint: %s" % (options.method, options.host, options.endpoint) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index d06085d..8ede975 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -32,33 +32,18 @@ class OpenPhotoHttp: self._token = token self._token_secret = token_secret - def get(self, endpoint, **params): + # Remember the most recent HTTP request and response + self.last_url = None + self.last_params = None + self.last_response = None + + def get(self, endpoint, process_response=True, **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. + Returns the decoded JSON dictionary, and raises exceptions if an + error code is received. + Returns the raw response if process_response=False """ params = self._process_params(params) url = urlparse.urlunparse(('http', self._host, endpoint, '', @@ -71,13 +56,25 @@ class OpenPhotoHttp: client = httplib2.Http() _, content = client.request(url, "GET") - return content - def post_raw(self, endpoint, **params): + self.last_url = url + self.last_params = params + self.last_response = content + + if process_response: + response = json.loads(content) + self._process_response(response) + return response + else: + return content + + def post(self, endpoint, process_response=True, **params): """ Performs an HTTP POST to the specified endpoint (API path), passing parameters if given. - Returns the raw HTTP content string. + Returns the decoded JSON dictionary, and raises exceptions if an + error code is received. + Returns the raw response if process_response=False """ params = self._process_params(params) url = urlparse.urlunparse(('http', self._host, endpoint, '', '', '')) @@ -91,7 +88,17 @@ class OpenPhotoHttp: client = oauth.Client(consumer, token) body = urllib.urlencode(params) _, content = client.request(url, "POST", body) - return content + + self.last_url = url + self.last_params = params + self.last_response = content + + if process_response: + response = json.loads(content) + self._process_response(response) + return response + else: + return content @staticmethod def _process_params(params): From 061ff8e7af87080735bdf67b65367a95da420594 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Wed, 29 Aug 2012 16:54:47 +0100 Subject: [PATCH 03/13] Fixed photos batch methods --- openphoto/api_photo.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/openphoto/api_photo.py b/openphoto/api_photo.py index 03d1145..ddc4376 100644 --- a/openphoto/api_photo.py +++ b/openphoto/api_photo.py @@ -46,14 +46,12 @@ class ApiPhoto(OpenPhotoHttp): def photos_update(self, photo_ids, **kwds): """ Updates a list of photos """ - if not self._openphoto.post("/photos/update.json" % photo_ids, - **kwds)["result"]: + if not self.post("/photos/update.json", ids=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"]: + if not self.post("/photos/delete.json", ids=photo_ids, **kwds)["result"]: raise OpenPhotoError("Delete response returned False") def photo_upload(self, photo_file, **kwds): From b1fe3989526c2712360de789318f2b85d11cb03b Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Thu, 30 Aug 2012 18:54:43 +0100 Subject: [PATCH 04/13] Print the raw response content if JSON parsing fails --- openphoto/openphoto_http.py | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 8ede975..0a09a54 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -62,8 +62,7 @@ class OpenPhotoHttp: self.last_response = content if process_response: - response = json.loads(content) - self._process_response(response) + return self._process_response(content) return response else: return content @@ -94,9 +93,7 @@ class OpenPhotoHttp: self.last_response = content if process_response: - response = json.loads(content) - self._process_response(response) - return response + return self._process_response(content) else: return content @@ -118,10 +115,20 @@ class OpenPhotoHttp: return processed_params @staticmethod - def _process_response(response): - """ Raises an exception if an invalid response code is received """ + def _process_response(content): + """ + Decodes the JSON response, returning a dict. + Raises an exception if an invalid response code is received. + """ + try: + response = json.loads(content) + except ValueError: + print "Response content:\n%s" % content + raise + if response["code"] >= 200 and response["code"] < 300: - return + # Valid response code + return response error_message = "Code %d: %s" % (response["code"], response["message"]) From 8307dca4541e1c04cbefd0ac14b806c6f1c9b205 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 2 Sep 2012 09:00:27 +0100 Subject: [PATCH 05/13] On second thoughts, don't print anything on JSON error, since the response is stored in .last_response anyway --- openphoto/openphoto_http.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 0a09a54..a9881f6 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -120,11 +120,7 @@ class OpenPhotoHttp: Decodes the JSON response, returning a dict. Raises an exception if an invalid response code is received. """ - try: - response = json.loads(content) - except ValueError: - print "Response content:\n%s" % content - raise + response = json.loads(content) if response["code"] >= 200 and response["code"] < 300: # Valid response code From 21c38c53cb2bafe548dc2c86861f5ca897895ab3 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 2 Sep 2012 09:02:18 +0100 Subject: [PATCH 06/13] Move api_ classes to be members of OpenPhoto (eg: client.photos_list --> client.photos.list) --- openphoto/__init__.py | 22 +++++++++--- openphoto/api_album.py | 41 ++++++++++++--------- openphoto/api_photo.py | 82 +++++++++++++++++++++++------------------- openphoto/api_tag.py | 34 +++++++++++------- 4 files changed, 107 insertions(+), 72 deletions(-) diff --git a/openphoto/__init__.py b/openphoto/__init__.py index 3481837..c118d4a 100644 --- a/openphoto/__init__.py +++ b/openphoto/__init__.py @@ -1,8 +1,20 @@ from openphoto_http import OpenPhotoHttp, OpenPhotoError, OpenPhotoDuplicateError -from api_photo import ApiPhoto -from api_tag import ApiTag -from api_album import ApiAlbum +import api_photo +import api_tag +import api_album -class OpenPhoto(OpenPhotoHttp, ApiPhoto, ApiTag, ApiAlbum): +class OpenPhoto(OpenPhotoHttp): """ Client library for OpenPhoto """ - pass + def __init__(self, host, + consumer_key='', consumer_secret='', + token='', token_secret=''): + OpenPhotoHttp.__init__(self, host, + consumer_key, consumer_secret, + token, token_secret) + + self.photos = api_photo.ApiPhotos(self) + self.photo = api_photo.ApiPhoto(self) + self.tags = api_tag.ApiTags(self) + self.tag = api_tag.ApiTag(self) + self.albums = api_album.ApiAlbums(self) + self.album = api_album.ApiAlbum(self) diff --git a/openphoto/api_album.py b/openphoto/api_album.py index e2ad812..a4628fd 100644 --- a/openphoto/api_album.py +++ b/openphoto/api_album.py @@ -1,33 +1,40 @@ 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) +class ApiAlbums: + def __init__(self, client): + self._client = client - def album_delete(self, album_id, **kwds): + def list(self, **kwds): + """ Return a list of Album objects """ + results = self._client.get("/albums/list.json", **kwds)["result"] + return [Album(self._client, album) for album in results] + +class ApiAlbum: + def __init__(self, client): + self._client = client + + def create(self, name, **kwds): + """ Create a new album and return it""" + result = self._client.post("/album/create.json", name=name, **kwds)["result"] + return Album(self._client, result) + + def delete(self, album, **kwds): """ Delete an album """ - album = Album(self, {"id": album_id}) + album = Album(self._client, {"id": album}) album.delete(**kwds) - def album_form(self, album_id, **kwds): + def form(self, album, **kwds): raise NotImplementedError() - def album_add_photos(self, album_id, photo_ids, **kwds): + def add_photos(self, album, photos, **kwds): raise NotImplementedError() - def album_remove_photos(self, album_id, photo_ids, **kwds): + def remove_photos(self, album, photos, **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): + def update(self, album, **kwds): """ Update an album """ - album = Album(self, {"id": album_id}) + album = Album(self._client, {"id": album}) 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 index ddc4376..6109388 100644 --- a/openphoto/api_photo.py +++ b/openphoto/api_photo.py @@ -3,77 +3,85 @@ import base64 from openphoto_http import OpenPhotoHttp, OpenPhotoError from objects import Photo -class ApiPhoto(OpenPhotoHttp): - def photo_delete(self, photo_id, **kwds): +class ApiPhotos: + def __init__(self, client): + self._client = client + + def list(self, **kwds): + """ Returns a list of Photo objects """ + photos = self._client.get("/photos/list.json", **kwds)["result"] + photos = self._client._result_to_list(photos) + return [Photo(self._client, photo) for photo in photos] + + def update(self, photos, **kwds): + """ Updates a list of photos """ + if not self._client.post("/photos/update.json", ids=photos, **kwds)["result"]: + raise OpenPhotoError("Update response returned False") + + def delete(self, photos, **kwds): + """ Deletes a list of photos """ + if not self._client.post("/photos/delete.json", ids=photos, **kwds)["result"]: + raise OpenPhotoError("Delete response returned False") + + +class ApiPhoto: + def __init__(self, client): + self._client = client + + def delete(self, photo, **kwds): """ Delete a photo """ - photo = Photo(self, {"id": photo_id}) + photo = Photo(self._client, {"id": photo}) photo.delete(**kwds) - def photo_edit(self, photo_id, **kwds): + def edit(self, photo, **kwds): """ Returns an HTML form to edit a photo """ - photo = Photo(self, {"id": photo_id}) + photo = Photo(self._client, {"id": photo}) return photo.edit(**kwds) - def photo_replace(self, photo_id, photo_file, **kwds): + def replace(self, photo, photo_file, **kwds): raise NotImplementedError() - def photo_replace_encoded(self, photo_id, photo_file, **kwds): + def replace_encoded(self, photo, photo_file, **kwds): raise NotImplementedError() - def photo_update(self, photo_id, **kwds): + def update(self, photo, **kwds): """ Update a photo with the specified parameters. Returns the updated photo object """ - photo = Photo(self, {"id": photo_id}) + photo = Photo(self._client, {"id": photo}) photo.update(**kwds) return photo - def photo_view(self, photo_id, **kwds): + def view(self, photo, **kwds): """ Used to view the photo at a particular size. Returns the requested photo object """ - photo = Photo(self, {"id": photo_id}) + photo = Photo(self._client, {"id": photo}) 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 upload(self, photo_file, **kwds): + raise NotImplementedError("Use upload_encoded instead.") - def photos_update(self, photo_ids, **kwds): - """ Updates a list of photos """ - if not self.post("/photos/update.json", ids=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.post("/photos/delete.json", ids=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): + def 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) + result = self._client.post("/photo/upload.json", photo=encoded_photo, + **kwds)["result"] + return Photo(self._client, result) - def photo_dynamic_url(self, photo_id, **kwds): + def dynamic_url(self, photo, **kwds): raise NotImplementedError() - def photo_next_previous(self, photo_id, **kwds): + def next_previous(self, photo, **kwds): """ Returns a dict containing the next and previous photo objects, given a photo in the middle. """ - photo = Photo(self, {"id": photo_id}) + photo = Photo(self._client, {"id": photo}) return photo.next_previous(**kwds) - def photo_transform(self, photo_id, **kwds): + def transform(self, photo, **kwds): raise NotImplementedError() diff --git a/openphoto/api_tag.py b/openphoto/api_tag.py index c7c4a7f..9360ecf 100644 --- a/openphoto/api_tag.py +++ b/openphoto/api_tag.py @@ -1,25 +1,33 @@ 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) +class ApiTags: + def __init__(self, client): + self._client = client - def tag_delete(self, tag_id, **kwds): + def list(self, **kwds): + """ Returns a list of Tag objects """ + results = self._client.get("/tags/list.json", **kwds)["result"] + return [Tag(self._client, tag) for tag in results] + +class ApiTag: + def __init__(self, client): + self._client = client + + def create(self, tag, **kwds): + """ Create a new tag and return it """ + result = self._client.post("/tag/create.json", tag=tag, **kwds)["result"] + return Tag(self._client, result) + + def delete(self, tag, **kwds): """ Delete a tag """ - tag = Tag(self, {"id": tag_id}) + tag = Tag(self._client, {"id": tag}) tag.delete(**kwds) - def tag_update(self, tag_id, **kwds): + def update(self, tag, **kwds): """ Update a tag """ - tag = Tag(self, {"id": tag_id}) + tag = Tag(self._client, {"id": tag}) 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] From fafeb70ec12b7c7c295705e75ccba11f52af9df7 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Tue, 4 Sep 2012 09:08:57 +0100 Subject: [PATCH 07/13] If an object is passed as a parameter, extract its ID. This allows things like: photos = client.photos.list() client.photos.delete(photos) --- openphoto/__init__.py | 3 ++- openphoto/api_album.py | 8 +++++--- openphoto/api_photo.py | 17 +++++++++++------ openphoto/api_tag.py | 10 +++++----- openphoto/errors.py | 12 ++++++++++++ openphoto/objects.py | 2 +- openphoto/openphoto_http.py | 29 +++++++++++++++++------------ 7 files changed, 53 insertions(+), 28 deletions(-) create mode 100644 openphoto/errors.py diff --git a/openphoto/__init__.py b/openphoto/__init__.py index c118d4a..7e497bd 100644 --- a/openphoto/__init__.py +++ b/openphoto/__init__.py @@ -1,4 +1,5 @@ -from openphoto_http import OpenPhotoHttp, OpenPhotoError, OpenPhotoDuplicateError +from openphoto_http import OpenPhotoHttp +from errors import * import api_photo import api_tag import api_album diff --git a/openphoto/api_album.py b/openphoto/api_album.py index a4628fd..4820ecf 100644 --- a/openphoto/api_album.py +++ b/openphoto/api_album.py @@ -1,4 +1,4 @@ -from openphoto_http import OpenPhotoHttp, OpenPhotoError +from errors import * from objects import Album class ApiAlbums: @@ -21,7 +21,8 @@ class ApiAlbum: def delete(self, album, **kwds): """ Delete an album """ - album = Album(self._client, {"id": album}) + if not isinstance(album, Album): + album = Album(self._client, {"id": album}) album.delete(**kwds) def form(self, album, **kwds): @@ -35,6 +36,7 @@ class ApiAlbum: def update(self, album, **kwds): """ Update an album """ - album = Album(self._client, {"id": album}) + if not isinstance(album, Album): + album = Album(self._client, {"id": album}) 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 index 6109388..0686a88 100644 --- a/openphoto/api_photo.py +++ b/openphoto/api_photo.py @@ -1,6 +1,6 @@ import base64 -from openphoto_http import OpenPhotoHttp, OpenPhotoError +from errors import * from objects import Photo class ApiPhotos: @@ -30,12 +30,14 @@ class ApiPhoto: def delete(self, photo, **kwds): """ Delete a photo """ - photo = Photo(self._client, {"id": photo}) + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) photo.delete(**kwds) def edit(self, photo, **kwds): """ Returns an HTML form to edit a photo """ - photo = Photo(self._client, {"id": photo}) + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) return photo.edit(**kwds) def replace(self, photo, photo_file, **kwds): @@ -49,7 +51,8 @@ class ApiPhoto: Update a photo with the specified parameters. Returns the updated photo object """ - photo = Photo(self._client, {"id": photo}) + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) photo.update(**kwds) return photo @@ -58,7 +61,8 @@ class ApiPhoto: Used to view the photo at a particular size. Returns the requested photo object """ - photo = Photo(self._client, {"id": photo}) + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) photo.view(**kwds) return photo @@ -80,7 +84,8 @@ class ApiPhoto: Returns a dict containing the next and previous photo objects, given a photo in the middle. """ - photo = Photo(self._client, {"id": photo}) + if not isinstance(photo, Photo): + photo = Photo(self._client, {"id": photo}) return photo.next_previous(**kwds) def transform(self, photo, **kwds): diff --git a/openphoto/api_tag.py b/openphoto/api_tag.py index 9360ecf..89f9fee 100644 --- a/openphoto/api_tag.py +++ b/openphoto/api_tag.py @@ -1,4 +1,4 @@ -from openphoto_http import OpenPhotoHttp, OpenPhotoError +from errors import * from objects import Tag class ApiTags: @@ -21,13 +21,13 @@ class ApiTag: def delete(self, tag, **kwds): """ Delete a tag """ - tag = Tag(self._client, {"id": tag}) + if not isinstance(tag, Tag): + tag = Tag(self._client, {"id": tag}) tag.delete(**kwds) def update(self, tag, **kwds): """ Update a tag """ - tag = Tag(self._client, {"id": tag}) + if not isinstance(tag, Tag): + tag = Tag(self._client, {"id": tag}) tag.update(**kwds) return tag - - diff --git a/openphoto/errors.py b/openphoto/errors.py new file mode 100644 index 0000000..25a0b24 --- /dev/null +++ b/openphoto/errors.py @@ -0,0 +1,12 @@ +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 + diff --git a/openphoto/objects.py b/openphoto/objects.py index 2af570f..0a5586a 100644 --- a/openphoto/objects.py +++ b/openphoto/objects.py @@ -1,4 +1,4 @@ -from openphoto_http import OpenPhotoError, NotImplementedError +from errors import * class OpenPhotoObject: """ Base object supporting the storage of custom fields as attributes """ diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index a9881f6..bcc8b3a 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -7,17 +7,8 @@ try: 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 +from objects import OpenPhotoObject +from errors import * DUPLICATE_RESPONSE = {"code": 409, "message": "This photo already exists"} @@ -102,16 +93,30 @@ class OpenPhotoHttp: """ Converts Unicode/lists/booleans inside HTTP parameters """ processed_params = {} for key, value in params.items(): + # Extract IDs from objects + if isinstance(value, OpenPhotoObject): + value = value.id + # Use UTF-8 encoding if isinstance(value, unicode): value = value.encode('utf-8') + # Handle lists if isinstance(value, list): - value = ",".join(value) + # Make a copy of the list, to avoid overwriting the original + new_list = list(value) + # Extract IDs from objects in the list + for i, item in enumerate(new_list): + if isinstance(item, OpenPhotoObject): + new_list[i] = item.id + # Convert list to string + value = ",".join(new_list) + # Handle booleans if isinstance(value, bool): value = 1 if value else 0 processed_params[key] = value + return processed_params @staticmethod From f3c48d4f438b7f69a7ada7f994669f185c354708 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Tue, 4 Sep 2012 17:39:03 +0100 Subject: [PATCH 08/13] Add missing view method to ApiAlbum. --- openphoto/api_album.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openphoto/api_album.py b/openphoto/api_album.py index 4820ecf..f20c94f 100644 --- a/openphoto/api_album.py +++ b/openphoto/api_album.py @@ -40,3 +40,13 @@ class ApiAlbum: album = Album(self._client, {"id": album}) album.update(**kwds) # Don't return the album, since the API doesn't give us the modified album + + def view(self, album, **kwds): + """ + View an album's contents. + Returns the requested album object. + """ + if not isinstance(album, Album): + album = Album(self._client, {"id": album}) + album.view(**kwds) + return album From 854c4fd605fcaaab616aba17a148b10b34c711b0 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Tue, 4 Sep 2012 20:36:26 +0100 Subject: [PATCH 09/13] Small refactor of code that automatically converts returned dicts into objects for Album commands. Add comments to handle API change when/if frontend issue number 937 lands --- openphoto/api_album.py | 5 ++++- openphoto/objects.py | 30 +++++++++++++++++++----------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/openphoto/api_album.py b/openphoto/api_album.py index f20c94f..90bc47d 100644 --- a/openphoto/api_album.py +++ b/openphoto/api_album.py @@ -39,7 +39,10 @@ class ApiAlbum: if not isinstance(album, Album): album = Album(self._client, {"id": album}) album.update(**kwds) - # Don't return the album, since the API doesn't give us the modified album + + # Don't return the album, since the API currently doesn't give us the modified album + # Uncomment the following once frontend issue #937 is resolved +# return album def view(self, album, **kwds): """ diff --git a/openphoto/objects.py b/openphoto/objects.py index 0a5586a..a2328bf 100644 --- a/openphoto/objects.py +++ b/openphoto/objects.py @@ -104,9 +104,18 @@ class Tag(OpenPhotoObject): 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) + self._update_fields_with_objects() + + def _update_fields_with_objects(self): + """ Convert dict fields into objects, where appropriate """ + # Update the cover with a photo object + if hasattr(self, "cover") and isinstance(self.cover, dict): + self.cover = Photo(self._openphoto, self.cover) + # Update the photo list with photo objects + if hasattr(self, "photos") and isinstance(self.photos, list): + for i, photo in enumerate(self.photos): + if isinstance(photo, dict): + self.photos[i] = Photo(self._openphoto, photo) def delete(self, **kwds): """ Delete this album """ @@ -124,12 +133,16 @@ class Album(OpenPhotoObject): def update(self, **kwds): """ Update this album with the specified parameters """ - self._openphoto.post("/album/%s/update.json" % self.id, - **kwds)["result"] + new_dict = 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) + # Replace the above line with the below once frontend issue #937 is resolved +# self._set_fields(new_dict) +# self._update_fields_with_objects() def view(self, **kwds): """ @@ -138,10 +151,5 @@ class Album(OpenPhotoObject): """ 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) + self._update_fields_with_objects() From ba5be69bec6bf7dbfd1ac49342ea19ef94ea9d5e Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Tue, 4 Sep 2012 22:08:48 +0100 Subject: [PATCH 10/13] Added tests for Python API library --- .gitignore | 3 +- tests/README.markdown | 61 ++++++++++++++++ tests/__init__.py | 0 tests/test_albums.py | 88 +++++++++++++++++++++++ tests/test_base.py | 128 ++++++++++++++++++++++++++++++++++ tests/test_photo1.jpg | Bin 0 -> 1468 bytes tests/test_photo2.jpg | Bin 0 -> 854 bytes tests/test_photo3.jpg | Bin 0 -> 579 bytes tests/test_photos.py | 157 ++++++++++++++++++++++++++++++++++++++++++ tests/test_tags.py | 71 +++++++++++++++++++ 10 files changed, 507 insertions(+), 1 deletion(-) create mode 100644 tests/README.markdown create mode 100644 tests/__init__.py create mode 100644 tests/test_albums.py create mode 100644 tests/test_base.py create mode 100644 tests/test_photo1.jpg create mode 100644 tests/test_photo2.jpg create mode 100644 tests/test_photo3.jpg create mode 100644 tests/test_photos.py create mode 100644 tests/test_tags.py diff --git a/.gitignore b/.gitignore index 8f8a3ff..1dab14d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ +*~ *.pyc build dist *.egg-info - +tests/tokens.py diff --git a/tests/README.markdown b/tests/README.markdown new file mode 100644 index 0000000..00a21df --- /dev/null +++ b/tests/README.markdown @@ -0,0 +1,61 @@ +Tests for the Open Photo API / Python Library +======================= +#### OpenPhoto, a photo service for the masses + +---------------------------------------- + +### Requirements +A computer, Python 2.7 and an empty OpenPhoto instance. + +--------------------------------------- + +### Setting up + +Create a tests/tokens.py file containing the following: + + # tests/token.py + consumer_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + consumer_secret = "xxxxxxxxxx" + token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" + token_secret = "xxxxxxxxxx" + host = "your_hostname" + +Make sure this is an empty test server, **not a production OpenPhoto server!!!** + +--------------------------------------- + +### Running the tests + + cd /path/to/openphoto-python + python -m unittest discover -c + +The "-c" lets you stop the tests gracefully with \[CTRL\]-c. + +The easiest way to run a subset of the tests is with nose: + + cd /path/to/openphoto-python + nosetests -v -s tests/test_albums.py:TestAlbums.test_view + +--------------------------------------- + +### Test Details + +These tests are intended to verify the Python library. They don't provide comprehensive testing of the OpenPhoto API, there are PHP unit tests for that. + +Each test class is run as follows: + +**SetUpClass:** + +Check that the server is empty + +**SetUp:** + +Ensure there are: + + * Three test photos + * A single test tag applied to each + * A single album containing all three photos + +**TearDownClass:** + +Remove all photos, tags and albums diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_albums.py b/tests/test_albums.py new file mode 100644 index 0000000..0c5fe11 --- /dev/null +++ b/tests/test_albums.py @@ -0,0 +1,88 @@ +import unittest +import openphoto +import test_base + +class TestAlbums(test_base.TestBase): + + def test_create_delete(self): + """ Create an album then delete it """ + album_name = "create_delete_album" + album = self.client.album.create(album_name, visible=True) + + # Check the return value + self.assertEqual(album.name, album_name) + # Check that the album now exists + self.assertIn(album_name, [a.name for a in self.client.albums.list()]) + + # Delete the album + self.client.album.delete(album.id) + # Check that the album is now gone + self.assertNotIn(album_name, [a.name for a in self.client.albums.list()]) + + # Create it again, and delete it using the Album object + album = self.client.album.create(album_name, visible=True) + album.delete() + # Check that the album is now gone + self.assertNotIn(album_name, [a.name for a in self.client.albums.list()]) + + def test_update(self): + """ Test that an album can be updated """ + # Update the album using the OpenPhoto class, passing in the album object + new_name = "New Name" + self.client.album.update(self.albums[0], name=new_name) + + # Check that the album is updated + self.albums = self.client.albums.list() + self.assertEqual(self.albums[0].name, new_name) + + # Update the album using the OpenPhoto class, passing in the album id + new_name = "Another New Name" + self.client.album.update(self.albums[0].id, name=new_name) + + # Check that the album is updated + self.albums = self.client.albums.list() + self.assertEqual(self.albums[0].name, new_name) + + # Update the album using the Album object directly + self.albums[0].update(name=self.TEST_ALBUM) + + # Check that the album is updated + self.albums = self.client.albums.list() + self.assertEqual(self.albums[0].name, self.TEST_ALBUM) + + def test_view(self): + """ Test the album view """ + album = self.albums[0] + self.assertFalse(hasattr(album, "photos")) + + # Get the photos in the album using the Album object directly + album.view() + # Make sure all photos are in the album + for photo in self.photos: + self.assertIn(photo.id, [p.id for p in album.photos]) + + @unittest.expectedFailure # Private albums are not visible - issue #929 + def test_private(self): + """ Test that private albums can be created, and are visible """ + # Create and check that the album now exists + album = self.client.album.create(album_name, visible=False) + self.assertIn(album_name, self.client.albums.list()) + + # Delete and check that the album is now gone + album.delete() + self.assertNotIn(album_name, self.client.albums.list()) + + def test_form(self): + """ If album.form gets implemented, write a test! """ + with self.assertRaises(openphoto.NotImplementedError): + self.client.album.form(None) + + def test_add_photos(self): + """ If album.add_photos gets implemented, write a test! """ + with self.assertRaises(openphoto.NotImplementedError): + self.client.album.add_photos(None, None) + + def test_remove_photos(self): + """ If album.remove_photos gets implemented, write a test! """ + with self.assertRaises(openphoto.NotImplementedError): + self.client.album.remove_photos(None, None) diff --git a/tests/test_base.py b/tests/test_base.py new file mode 100644 index 0000000..8e9b980 --- /dev/null +++ b/tests/test_base.py @@ -0,0 +1,128 @@ +import unittest +import openphoto + +try: + import tokens +except ImportError: + print ("********************************************************************\n" + "You need to create a 'tokens.py' file containing the following:\n\n" + " host = \"\"\n" + " consumer_key = \"\"\n" + " consumer_secret = \"\"\n" + " token = \"\"\n" + " token_secret = \"\"\n" + " host = \"\"\n\n" + "WARNING: Don't use a production OpenPhoto instance for this!\n" + "********************************************************************\n") + raise + +class TestBase(unittest.TestCase): + TEST_TITLE = "Test Image - delete me!" + TEST_TAG = "test_tag" + TEST_ALBUM = "test_album" + MAXIMUM_TEST_PHOTOS = 4 # Never have more the 4 photos on the test server + + def __init__(self, *args, **kwds): + unittest.TestCase.__init__(self, *args, **kwds) + self.photos = [] + + @classmethod + def setUpClass(cls): + """ Ensure there is nothing on the server before running any tests """ + cls.client = openphoto.OpenPhoto(tokens.host, + tokens.consumer_key, tokens.consumer_secret, + tokens.token, tokens.token_secret) + + if cls.client.photos.list() != []: + raise ValueError("The test server (%s) contains photos. " + "Please delete them before running the tests" + % tokens.host) + + if cls.client.tags.list() != []: + raise ValueError("The test server (%s) contains tags. " + "Please delete them before running the tests" + % tokens.host) + + if cls.client.albums.list() != []: + raise ValueError("The test server (%s) contains albums. " + "Please delete them before running the tests" + % tokens.host) + + @classmethod + def tearDownClass(cls): + """ Once all tests have finished, delete all photos, tags and albums""" + cls._delete_all() + + def setUp(self): + """ + Ensure the three test photos are present before each test. + Give them each a tag. + Put them into an album. + """ + self.photos = self.client.photos.list() + if len(self.photos) != 3: + print "[Regenerating Photos]" + if len(self.photos) > 0: + self._delete_all() + self._create_test_photos() + self.photos = self.client.photos.list() + + self.tags = self.client.tags.list() + if (len(self.tags) != 1 or + self.tags[0].id != self.TEST_TAG or + self.tags[0].count != "3"): + print "[Regenerating Tags]" + self._delete_all() + self._create_test_photos() + self.photos = self.client.photos.list() + self.tags = self.client.tags.list() + if len(self.tags) != 1: + print "Tags: %s" % self.tags + raise Exception("Tag creation failed") + + self.albums = self.client.albums.list() + if (len(self.albums) != 1 or + self.albums[0].name != self.TEST_ALBUM or + self.albums[0].count != "3"): + print "[Regenerating Albums]" + self._delete_all() + self._create_test_photos() + self.photos = self.client.photos.list() + self.tags = self.client.tags.list() + self.albums = self.client.albums.list() + if len(self.albums) != 1: + print "Albums: %s" % self.albums + raise Exception("Album creation failed") + + @classmethod + def _create_test_photos(cls): + """ Upload three test photos """ + album = cls.client.album.create(cls.TEST_ALBUM, visible=True) + photos = [ + cls.client.photo.upload_encoded("tests/test_photo1.jpg", + title=cls.TEST_TITLE, + tags=cls.TEST_TAG), + cls.client.photo.upload_encoded("tests/test_photo2.jpg", + title=cls.TEST_TITLE, + tags=cls.TEST_TAG), + cls.client.photo.upload_encoded("tests/test_photo3.jpg", + title=cls.TEST_TITLE, + tags=cls.TEST_TAG), + ] + # Remove the auto-generated month/year tags + tags_to_remove = [p for p in photos[0].tags if p != cls.TEST_TAG] + for photo in photos: + photo.update(tagsRemove=tags_to_remove, albums=album.id) + + @classmethod + def _delete_all(cls): + photos = cls.client.photos.list() + if len(photos) > cls.MAXIMUM_TEST_PHOTOS: + raise ValueError("There too many photos on the test server - must always be less than %d." + % cls.MAXIMUM_TEST_PHOTOS) + for photo in photos: + photo.delete() + for tag in cls.client.tags.list(): + tag.delete() + for album in cls.client.albums.list(): + album.delete() diff --git a/tests/test_photo1.jpg b/tests/test_photo1.jpg new file mode 100644 index 0000000000000000000000000000000000000000..799c86ba93404340b856a018ed42fc6295072730 GIT binary patch literal 1468 zcmYjL3pCRU82|q_W8~3lCS_u5n0ah%3%Q9%BQ)|zQ+b70^3G$|5jj0XR_^kCm5y;` zS>6`0R`L$m?r?;51OPz*0HqB` zMu9XyURD+^3zLV-!BO(^3Th~2B}GLgG#aUa!fEJX_G)Ws>S+_O+C~Jdz9x>0$LdiC zMg)v5$=Z}k#SsVwpuB>D(l#Y^Wo31WuBI;Kdl9z4giPE*0-3ONFxlG3WMeA9#K441 zCKD(`iliMtz=1=+At;CeAP5kO03}_31^_?-2vnN#zkP#FLwi`bz}Bw4?f!`RS$Q?hwGkve`6`}|8gRCQgYvfbb}{r^0r3Q$H40!TR= z0)Rk38R@|O{Rl$7SxOyumJ+QoNP5KUWyuJjD9r>xKoNiy(D*9j@O3TU#*rwE{^$mE zrRk|6Lq^vhR|@u@i#*j9q{e-PU%;n~hqG%|$8KW>_iHc)IHCh=t`~1|)AOV9tOdO} zi5;CfPCa9ByxEzwYw;fH67;}$U@!hec6=70OCh2Isj{||?(Z{}5gcaWgpUXtG^pwBj~N25K9BpDD^>}QM@=w3 zMXyGkKGrrF50t4^$0wuKg-)Ih z6`=%tSyG_&SKt-y#>95PR0p?Zfczh|&Auk72&}<^esZ(u0_RjRV zq|w%5J%>FDef?X4Z3W1m>~Yh0c3E=2@sm%SGMauI{4*GdyQZYJ%!jm=+?va?Ld2GO zY-q0X-wIKoD}5x~)s!R--sG(AIt)3!gE8~Kfl7Mp*R6YXs%wdB*y+eGYTlepc!S-Q zh-IYJ4>%XZxfKev9-j<2!+WyEY26pIr|l*ux-J?44s6^m8ZmS|fd1gI_ih$HFRV$R z{*`7$Im#D1vflH(mP#61yeZFb7w7D(Wa5s?FCi!jDo~DfbFr6e?PA>yq@2n@UW~P6 z=zQojaE(bvK%j4U0MqIIbJA#iaZwOr!t6re_!O{ z^P{^>2A1wdhFO(W`23x|?k#K#DWC_KWg4fJ4+p+zaAePWUuGD6u5M7nya@4T-S4qu zy=gNH494M}Qs>=NA)yH>aKC!Kpj^c$Es!c2?BkV$q3AoOnWD~^bY@wmmYVA#%!?Hbkaxi-Gof5Z@ z*32n?zcxpIHd+E?#3ZZLUw#5!#x7BM3?k=}5`53C=1w<5dHVha)QA%MfFkUCy+YNd zo867F-mhaF2F&Cv-`M<7(Iw>x2KVe68+3b*k@MZDF2$1#qtoLxPF-pJts7Oht|vZ> z^l0~0G<~MZOQQ}uiRT0#PIdQCu}Ph~Y(}s6Rb9;Qza#~;k+`^QRlZ%U&n^%&LPtsIH zPQ?@iOiWGn!3+ptVyXiY0*M=$YJ+J#O(4faQwK==KfoZ!!C=5(z|5$`z$D1XEXer( z2!kX812ZGYr3k>v&c?z7lrj`xU}9uuW@6%GW`)QyGBGo-2(k((vKa~&vWq$fCN@qy z_(4fINJPcR#0jWHf)QjI!YCk%g_Q|pkFp@hFcwCrNk9okCI)5}K_Ov9!-pcGfr%IY z-(uilW(2y7S&+e=VaG*Rtyi&?R>dyUeg*Dr6EityzVnT}ZPN}9X0=?wRR*pyGp<`K zwp>wMb$i{rlv&p_^OD03rcFN3$p2CDUCpN2uTN92RtbGF-_@5LTKvnSUXHVlT_*iO zLG6`&Ki~Tc9p9=}_PuR?zn$v2uF%U-pGxIAMZY*Fo4=fBJh#uf|9q|KGDemZk9WPk zT?S8_mhYP}wQuTj(eo|p3HPcieqZI9Udq-fAMoOGM9kALztH2V5@m^zD|St3l-RUx z+jq{uU4rI|XPb8wb@@zd;nWWpEWnXyV-pVW9>4%Ni-7hQq{B4z}-pjv2cJnSi+_3GmPR-_d?~d=5 zN)CCsZYslx>07TodiYA;t>D#JrGw%PX`GMhUOOzjqsO06{Py;zvkGnB{$-?SrDa}n z^9(t*P?YUnNaN}6kLTyhu8wxi(=?iHE`IHn@7>$p8W|J6Y4#Rwad~3Ml5XAf{a3ht zd%fzN>t*MwT+IG6-2U;RBenK~*N)>k@7^}|e7?19OCV?0^lN&Fv&8nLcgU>i=;Ar1 z&STgk!k)*UGw;Wfj4$d_-!A*N;@k;UCAJ+7lZW(F2kK_Nv$$3V8ki6TlWjUPS&RZ224Ffqdvfz4rNVPa)t2TI8c0;L!j;YI)h zg^7V#P?1GQ$uV%^|62?^K#fd-%z_N|46i;n-7}jM_AoGbdSu68Ink%@vh!D~SLR>3 zr2Eq0>C^rXYs)I6y(>bOcBV~j(0OP6!8$B$)2fB@@4SdnP3*XoPA$Xuhe8s-gfs0*NLT5zNAWvx=%UgJYidgo_JQ3;8p*LftM4EC0_Dh@KNhq8p9n9KYH91EyUQnWLmDA{bFu`kBS!=OW>=z3#3@YbVTm(&sv}Z_i{;J8Auj s-}|Sy?fv#AH~R60zH5Ra-b-cA2W57B Date: Sun, 9 Sep 2012 13:07:31 +0100 Subject: [PATCH 11/13] Convert list parameters to unicode strings. Previously ["1", 2] would fail, since 2 is not a string. --- openphoto/openphoto_http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index bcc8b3a..2933d43 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -109,8 +109,8 @@ class OpenPhotoHttp: for i, item in enumerate(new_list): if isinstance(item, OpenPhotoObject): new_list[i] = item.id - # Convert list to string - value = ",".join(new_list) + # Convert list to unicode string + value = u','.join([unicode(item) for item in new_list]) # Handle booleans if isinstance(value, bool): From b900b2abd3566299ffd822fd4d9a2e7a231cf639 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 9 Sep 2012 13:07:57 +0100 Subject: [PATCH 12/13] Fix private album testcase --- tests/test_albums.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_albums.py b/tests/test_albums.py index 0c5fe11..3b59ea8 100644 --- a/tests/test_albums.py +++ b/tests/test_albums.py @@ -65,12 +65,13 @@ class TestAlbums(test_base.TestBase): def test_private(self): """ Test that private albums can be created, and are visible """ # Create and check that the album now exists + album_name = "private_album" album = self.client.album.create(album_name, visible=False) - self.assertIn(album_name, self.client.albums.list()) + self.assertIn(album_name, [a.name for a in self.client.albums.list()]) # Delete and check that the album is now gone album.delete() - self.assertNotIn(album_name, self.client.albums.list()) + self.assertNotIn(album_name, [a.name for a in self.client.albums.list()]) def test_form(self): """ If album.form gets implemented, write a test! """ From 0ef3abe79e7270f8f0ff450cf5e8f117d818a2ec Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Fri, 21 Dec 2012 18:23:42 +0000 Subject: [PATCH 13/13] Updated README with usage fixes and additional description of how the classes map to the OpenPhoto API --- README.markdown | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/README.markdown b/README.markdown index e9bbd38..fda226d 100644 --- a/README.markdown +++ b/README.markdown @@ -15,6 +15,7 @@ Open Photo API / Python 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 @@ -31,16 +32,21 @@ You can use the library in one of two ways: from openphoto import OpenPhoto client = OpenPhoto(host, consumerKey, consumerSecret, token, tokenSecret) - photos = client.photos_list() + photos = client.photos.list() photos[0].update(tags=["tag1", "tag2"]) print photos[0].tags +The OpenPhoto Python class hierarchy mirrors the [OpenPhoto API](http://theopenphotoproject.org/documentation) endpoint layout. For example, the calls in the example above use the following API endpoints: + +* client.photos.list() -> /photos/list.json +* photos[0].update() -> /photo/<id>/update.json + ---------------------------------------- ### Using from the command line -You'll then want to export your secrets to the environment. +When using the command line tools, you'll want to export your secrets to the environment. We suggest putting them in a file and sourcing it prior to running `openphoto` commands. Click here for instructions on getting credentials. @@ -113,3 +119,4 @@ Now you can run commands to the OpenPhoto API from your shell! You can get your credentals by clicking on the arrow next to your email address once you're logged into your site and then clicking on settings. If you don't have any credentials then you can create one for yourself by going to `/v1/oauth/flow`. Once completed go back to the settings page and you should see the credential you just created +