From b418cf7e78de5ce3021952af9017af1f2d0ecf49 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Tue, 28 Aug 2012 18:59:33 +0100 Subject: [PATCH 01/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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 + From 682a2ac85d8fba3df39e20cd5a7de2672658f5c6 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 14:57:39 +0000 Subject: [PATCH 14/31] Handle the case where an empty response is returned (possibly related to Issue #1086) --- openphoto/openphoto_http.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 2933d43..82d11bb 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -144,6 +144,8 @@ class OpenPhotoHttp: @staticmethod def _result_to_list(result): """ Handle the case where the result contains no items """ + if not result: + return [] if result[0]["totalRows"] == 0: return [] else: From 889c5fa79a17abf45095d14dc1288fbc829d9b58 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 14:58:08 +0000 Subject: [PATCH 15/31] Fix incorrect filename in comment --- tests/README.markdown | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/README.markdown b/tests/README.markdown index 00a21df..92e250f 100644 --- a/tests/README.markdown +++ b/tests/README.markdown @@ -13,7 +13,7 @@ A computer, Python 2.7 and an empty OpenPhoto instance. Create a tests/tokens.py file containing the following: - # tests/token.py + # tests/tokens.py consumer_key = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" consumer_secret = "xxxxxxxxxx" token = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" From be63881301e346fa2c82a42982270ae653d4b703 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 15:31:34 +0000 Subject: [PATCH 16/31] Add EXIF date/time to test images, so the next/previous links work correctly (workaround for issue #1038) --- tests/test_photo1.jpg | Bin 1468 -> 1524 bytes tests/test_photo2.jpg | Bin 854 -> 910 bytes tests/test_photo3.jpg | Bin 579 -> 635 bytes 3 files changed, 0 insertions(+), 0 deletions(-) diff --git a/tests/test_photo1.jpg b/tests/test_photo1.jpg index 799c86ba93404340b856a018ed42fc6295072730..6363b19cd1ac05e15ebdda19baba0c1a48f17a59 100644 GIT binary patch delta 65 zcmdnP{e^pisM$jXGuMjDGzJDwPb~%x1_lO31|vo$1`!}j3W!0#$iUFh3Wy9$6%5U- OOpUD!3^xj|WCZ}CK?|b* delta 10 Rcmeyuy@z{(=tke=tN3WnxZ Nrp8tVMjM5lnE{Nz3g-X- delta 10 RcmeBUzs5E}bfd2$GXNB*16cq7 diff --git a/tests/test_photo3.jpg b/tests/test_photo3.jpg index e3f708e9b77f70239a22201e73f1eb4b4ef74684..05fe10b9db7f473b001681fa7bbd8a30399c8f85 100644 GIT binary patch delta 65 zcmX@i@|$IXsM$jXGuMjDGzJDwPb~%x1_lO31|vo$1`!}j3W!0#$iUFh3Wy9$6%5U- OOpUD!j5i7!FaZFeWD6+( delta 10 Rcmey(a+qa;=tf^XCIA=$1DXH; From eeffec991fa9d03f9e83cad300bf487b31e90d35 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 14:57:39 +0000 Subject: [PATCH 17/31] Handle the case where an empty response is returned (possibly related to Issue #1086) --- openphoto/openphoto_http.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 2933d43..82d11bb 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -144,6 +144,8 @@ class OpenPhotoHttp: @staticmethod def _result_to_list(result): """ Handle the case where the result contains no items """ + if not result: + return [] if result[0]["totalRows"] == 0: return [] else: From 4ccdceb601c7a29ae4c5bed250b067fe85ebb7fc Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 16:54:14 +0000 Subject: [PATCH 18/31] Private (visible=False/True) albums are no longer supported. Tag count is now returned as an integer Explicitly add test tags, rather than removing autogenerated tags --- tests/test_albums.py | 16 ++-------------- tests/test_base.py | 16 ++++++++-------- 2 files changed, 10 insertions(+), 22 deletions(-) diff --git a/tests/test_albums.py b/tests/test_albums.py index 3b59ea8..d8a7244 100644 --- a/tests/test_albums.py +++ b/tests/test_albums.py @@ -7,7 +7,7 @@ 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) + album = self.client.album.create(album_name) # Check the return value self.assertEqual(album.name, album_name) @@ -20,7 +20,7 @@ class TestAlbums(test_base.TestBase): 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 = self.client.album.create(album_name) album.delete() # Check that the album is now gone self.assertNotIn(album_name, [a.name for a in self.client.albums.list()]) @@ -61,18 +61,6 @@ class TestAlbums(test_base.TestBase): 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_name = "private_album" - album = self.client.album.create(album_name, visible=False) - 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, [a.name for a in self.client.albums.list()]) - def test_form(self): """ If album.form gets implemented, write a test! """ with self.assertRaises(openphoto.NotImplementedError): diff --git a/tests/test_base.py b/tests/test_base.py index 8e9b980..9f89315 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -61,6 +61,7 @@ class TestBase(unittest.TestCase): """ self.photos = self.client.photos.list() if len(self.photos) != 3: +# print self.photos print "[Regenerating Photos]" if len(self.photos) > 0: self._delete_all() @@ -70,7 +71,7 @@ class TestBase(unittest.TestCase): 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"): + self.tags[0].count != 3): print "[Regenerating Tags]" self._delete_all() self._create_test_photos() @@ -97,22 +98,21 @@ class TestBase(unittest.TestCase): @classmethod def _create_test_photos(cls): """ Upload three test photos """ - album = cls.client.album.create(cls.TEST_ALBUM, visible=True) + album = cls.client.album.create(cls.TEST_ALBUM) photos = [ cls.client.photo.upload_encoded("tests/test_photo1.jpg", title=cls.TEST_TITLE, - tags=cls.TEST_TAG), + albums=album.id), cls.client.photo.upload_encoded("tests/test_photo2.jpg", title=cls.TEST_TITLE, - tags=cls.TEST_TAG), + albums=album.id), cls.client.photo.upload_encoded("tests/test_photo3.jpg", title=cls.TEST_TITLE, - tags=cls.TEST_TAG), + albums=album.id), ] - # Remove the auto-generated month/year tags - tags_to_remove = [p for p in photos[0].tags if p != cls.TEST_TAG] + # Add the test tag, removing any autogenerated tags for photo in photos: - photo.update(tagsRemove=tags_to_remove, albums=album.id) + photo.update(tags=cls.TEST_TAG) @classmethod def _delete_all(cls): From 895b98dedfc2278b5d2589156eaa893bc8056b41 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 17:10:44 +0000 Subject: [PATCH 19/31] Next/previous now returns a list of multiple photos (Issue #1004) --- openphoto/api_photo.py | 4 ++-- openphoto/objects.py | 13 ++++++++++--- tests/test_photos.py | 8 ++++---- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/openphoto/api_photo.py b/openphoto/api_photo.py index 0686a88..8eb561a 100644 --- a/openphoto/api_photo.py +++ b/openphoto/api_photo.py @@ -81,8 +81,8 @@ class ApiPhoto: def next_previous(self, photo, **kwds): """ - Returns a dict containing the next and previous photo objects, - given a photo in the middle. + Returns a dict containing the next and previous photo lists + (there may be more than one next/previous photo returned). """ if not isinstance(photo, Photo): photo = Photo(self._client, {"id": photo}) diff --git a/openphoto/objects.py b/openphoto/objects.py index a2328bf..965df9a 100644 --- a/openphoto/objects.py +++ b/openphoto/objects.py @@ -74,14 +74,21 @@ class Photo(OpenPhotoObject): raise NotImplementedError() def next_previous(self, **kwds): - """ Returns a dict containing the next and previous photo objects """ + """ + Returns a dict containing the next and previous photo lists + (there may be more than one next/previous photo returned). + """ result = self._openphoto.get("/photo/%s/nextprevious.json" % self.id, **kwds)["result"] value = {} if "next" in result: - value["next"] = Photo(self._openphoto, result["next"]) + value["next"] = [] + for photo in result["next"]: + value["next"].append(Photo(self._openphoto, photo)) if "previous" in result: - value["previous"] = Photo(self._openphoto, result["previous"]) + value["previous"] = [] + for photo in result["previous"]: + value["previous"].append(Photo(self._openphoto, photo)) return value def transform(self, **kwds): diff --git a/tests/test_photos.py b/tests/test_photos.py index 68e9cac..313f2ca 100644 --- a/tests/test_photos.py +++ b/tests/test_photos.py @@ -123,13 +123,13 @@ class TestPhotos(test_base.TestBase): def test_next_previous(self): """ Test the next/previous links of the middle photo """ next_prev = self.client.photo.next_previous(self.photos[1]) - self.assertEqual(next_prev["previous"].id, self.photos[0].id) - self.assertEqual(next_prev["next"].id, self.photos[2].id) + self.assertEqual(next_prev["previous"][0].id, self.photos[0].id) + self.assertEqual(next_prev["next"][0].id, self.photos[2].id) # Do the same using the Photo object directly next_prev = self.photos[1].next_previous() - self.assertEqual(next_prev["previous"].id, self.photos[0].id) - self.assertEqual(next_prev["next"].id, self.photos[2].id) + self.assertEqual(next_prev["previous"][0].id, self.photos[0].id) + self.assertEqual(next_prev["next"][0].id, self.photos[2].id) def test_replace(self): """ If photo.replace gets implemented, write a test! """ From d3eff3edb92aeea3d1e784000bb715caf22d91bd Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sat, 9 Feb 2013 18:51:59 +0000 Subject: [PATCH 20/31] album.view now requires includeElements=True in order for the album contents to be returned (issue #953) --- tests/test_albums.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_albums.py b/tests/test_albums.py index d8a7244..53ff7e9 100644 --- a/tests/test_albums.py +++ b/tests/test_albums.py @@ -56,7 +56,7 @@ class TestAlbums(test_base.TestBase): self.assertFalse(hasattr(album, "photos")) # Get the photos in the album using the Album object directly - album.view() + album.view(includeElements=True) # Make sure all photos are in the album for photo in self.photos: self.assertIn(photo.id, [p.id for p in album.photos]) From 29b4c65b9a275ea3c87263ca5350e10e8b5db232 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 17 Feb 2013 16:12:15 +0000 Subject: [PATCH 21/31] Update comment --- openphoto/api_album.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openphoto/api_album.py b/openphoto/api_album.py index 90bc47d..e997b65 100644 --- a/openphoto/api_album.py +++ b/openphoto/api_album.py @@ -41,7 +41,7 @@ class ApiAlbum: album.update(**kwds) # 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 + # TODO: Uncomment the following once frontend issue #937 is resolved # return album def view(self, album, **kwds): From 8d628b423a07e60deb84917d78a22e541a0f12a9 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 15:35:02 +0000 Subject: [PATCH 22/31] Add option to log all API calls/responses --- openphoto/__init__.py | 6 ++++-- openphoto/openphoto_http.py | 19 ++++++++++++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/openphoto/__init__.py b/openphoto/__init__.py index 7e497bd..9de6672 100644 --- a/openphoto/__init__.py +++ b/openphoto/__init__.py @@ -8,10 +8,12 @@ class OpenPhoto(OpenPhotoHttp): """ Client library for OpenPhoto """ def __init__(self, host, consumer_key='', consumer_secret='', - token='', token_secret=''): + token='', token_secret='', + log_filename=None): OpenPhotoHttp.__init__(self, host, consumer_key, consumer_secret, - token, token_secret) + token, token_secret, + log_filename) self.photos = api_photo.ApiPhotos(self) self.photo = api_photo.ApiPhoto(self) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 2933d43..36c2cb3 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -16,13 +16,16 @@ DUPLICATE_RESPONSE = {"code": 409, class OpenPhotoHttp: """ Base class to handle HTTP requests to an OpenPhoto server """ def __init__(self, host, consumer_key='', consumer_secret='', - token='', token_secret=''): + token='', token_secret='', log_filename=None): self._host = host self._consumer_key = consumer_key self._consumer_secret = consumer_secret self._token = token self._token_secret = token_secret + if log_filename: + self._logfile = open(log_filename, "w") + # Remember the most recent HTTP request and response self.last_url = None self.last_params = None @@ -48,6 +51,12 @@ class OpenPhotoHttp: _, content = client.request(url, "GET") + if self._logfile: + print >> self._logfile, "----------------------------" + print >> self._logfile, "GET %s" % url + print >> self._logfile, "----------------------------" + print >> self._logfile, content + self.last_url = url self.last_params = params self.last_response = content @@ -77,8 +86,16 @@ class OpenPhotoHttp: client = oauth.Client(consumer, token) body = urllib.urlencode(params) + _, content = client.request(url, "POST", body) + if self._logfile: + print >> self._logfile, "----------------------------" + print >> self._logfile, "POST %s" % url + print >> self._logfile, body + print >> self._logfile, "----------------------------" + print >> self._logfile, content + self.last_url = url self.last_params = params self.last_response = content From e9df18ebbbdd120b323241f7ca84710d343dabde Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 15:35:40 +0000 Subject: [PATCH 23/31] Log all API calls/responses during unit tests --- tests/test_base.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/test_base.py b/tests/test_base.py index 8e9b980..58bc7e6 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -16,6 +16,8 @@ except ImportError: "********************************************************************\n") raise +LOG_FILENAME = "tests.log" + class TestBase(unittest.TestCase): TEST_TITLE = "Test Image - delete me!" TEST_TAG = "test_tag" @@ -31,7 +33,8 @@ class TestBase(unittest.TestCase): """ 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) + tokens.token, tokens.token_secret, + log_filename=LOG_FILENAME) if cls.client.photos.list() != []: raise ValueError("The test server (%s) contains photos. " From 5a423ca7a2949a39ac1747f3460f2b27b4c09f3c Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 15:43:55 +0000 Subject: [PATCH 24/31] Ignore test logs --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 1dab14d..54bad91 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ build dist *.egg-info tests/tokens.py +tests.log From 4ecbf4ccd3b5e2d5a81d8738f18b04e6f7f595fa Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 16:51:35 +0000 Subject: [PATCH 25/31] Fix for when no logfile is specified --- openphoto/openphoto_http.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 36c2cb3..cfc0348 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -23,6 +23,7 @@ class OpenPhotoHttp: self._token = token self._token_secret = token_secret + self._logfile = None if log_filename: self._logfile = open(log_filename, "w") From 5858043d44092cbf84c4619a882302f97f16671b Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 17 Mar 2013 15:08:09 +0000 Subject: [PATCH 26/31] Add multipart form support. Does not yet handle additional post parameters correctly. --- openphoto/api_photo.py | 4 +++- openphoto/multipart_post.py | 30 ++++++++++++++++++++++++++++++ openphoto/openphoto_http.py | 14 ++++++++++---- 3 files changed, 43 insertions(+), 5 deletions(-) create mode 100644 openphoto/multipart_post.py diff --git a/openphoto/api_photo.py b/openphoto/api_photo.py index 0686a88..1601dc7 100644 --- a/openphoto/api_photo.py +++ b/openphoto/api_photo.py @@ -67,7 +67,9 @@ class ApiPhoto: return photo def upload(self, photo_file, **kwds): - raise NotImplementedError("Use upload_encoded instead.") + result = self._client.post("/photo/upload.json", files={'photo': photo_file}, + **kwds)["result"] + return Photo(self._client, result) def upload_encoded(self, photo_file, **kwds): """ Base64-encodes and uploads the specified file """ diff --git a/openphoto/multipart_post.py b/openphoto/multipart_post.py new file mode 100644 index 0000000..14c38ba --- /dev/null +++ b/openphoto/multipart_post.py @@ -0,0 +1,30 @@ +import mimetypes +import mimetools + +def encode_multipart_formdata(params, files): + boundary = mimetools.choose_boundary() + + lines = [] + for name in params: + lines.append("--" + boundary) + lines.append("Content-Disposition: form-data; name=\"%s\"" % name) + lines.append("") + lines.append(str(params[name])) + for name in files: + filename = files[name] + content_type, _ = mimetypes.guess_type(filename) + if content_type is None: + content_type = "application/octet-stream" + + lines.append("--" + boundary) + lines.append("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"" % (name, filename)) + lines.append("Content-Type: %s" % content_type) + lines.append("") + lines.append(open(filename, "rb").read()) + lines.append("--" + boundary + "--") + lines.append("") + + body = "\r\n".join(lines) + headers = {'Content-Type': "multipart/form-data; boundary=%s" % boundary, + 'Content-Length': str(len(body))} + return headers, body diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index 2933d43..a644edf 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -9,6 +9,7 @@ except ImportError: from objects import OpenPhotoObject from errors import * +from multipart_post import encode_multipart_formdata DUPLICATE_RESPONSE = {"code": 409, "message": "This photo already exists"} @@ -58,7 +59,7 @@ class OpenPhotoHttp: else: return content - def post(self, endpoint, process_response=True, **params): + def post(self, endpoint, process_response=True, files = {}, **params): """ Performs an HTTP POST to the specified endpoint (API path), passing parameters if given. @@ -74,10 +75,15 @@ class OpenPhotoHttp: 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) + + if files: + headers, body = encode_multipart_formdata(params, files) + else: + headers = {} + body = urllib.urlencode(params) + + _, content = client.request(url, "POST", body, headers) self.last_url = url self.last_params = params From e8ccfa823304d686917f71fafe4b5b79647c2e57 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 17 Mar 2013 15:35:28 +0000 Subject: [PATCH 27/31] Add support for OAuth-signed parameters when using multipart POST --- openphoto/openphoto_http.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index a644edf..83c9942 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -1,6 +1,7 @@ import oauth2 as oauth import urlparse import urllib +import urllib2 import httplib2 try: import json @@ -78,12 +79,14 @@ class OpenPhotoHttp: client = oauth.Client(consumer, token) if files: + # Parameters must be signed and encoded into the multipart body + params = self._sign_params(client, url, params) headers, body = encode_multipart_formdata(params, files) + request = urllib2.Request(url, body, headers) + content = urllib2.urlopen(request).read() else: - headers = {} body = urllib.urlencode(params) - - _, content = client.request(url, "POST", body, headers) + _, content = client.request(url, "POST", body) self.last_url = url self.last_params = params @@ -94,6 +97,17 @@ class OpenPhotoHttp: else: return content + @staticmethod + def _sign_params(client, url, params): + """Use OAuth to sign a dictionary of params""" + request = oauth.Request.from_consumer_and_token(consumer=client.consumer, + token=client.token, + http_method="POST", + http_url=url, + parameters=params) + request.sign_request(client.method, client.consumer, client.token) + return dict(urlparse.parse_qsl(request.to_postdata())) + @staticmethod def _process_params(params): """ Converts Unicode/lists/booleans inside HTTP parameters """ From d94c7fd8cdf7a6577b44c51d0091a69534bd0a43 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 17 Mar 2013 16:00:19 +0000 Subject: [PATCH 28/31] Update tests to use multipart upload --- tests/test_base.py | 18 +++++++++--------- tests/test_photos.py | 19 +++++++------------ 2 files changed, 16 insertions(+), 21 deletions(-) diff --git a/tests/test_base.py b/tests/test_base.py index 8e9b980..91a5095 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -99,15 +99,15 @@ class TestBase(unittest.TestCase): """ 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), + cls.client.photo.upload("tests/test_photo1.jpg", + title=cls.TEST_TITLE, + tags=cls.TEST_TAG), + cls.client.photo.upload("tests/test_photo2.jpg", + title=cls.TEST_TITLE, + tags=cls.TEST_TAG), + cls.client.photo.upload("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] diff --git a/tests/test_photos.py b/tests/test_photos.py index 68e9cac..a81ed33 100644 --- a/tests/test_photos.py +++ b/tests/test_photos.py @@ -15,11 +15,11 @@ class TestPhotos(test_base.TestBase): # Check that they're gone self.assertEqual(self.client.photos.list(), []) - # Re-upload the photos - ret_val = self.client.photo.upload_encoded("tests/test_photo1.jpg", - title=self.TEST_TITLE) - self.client.photo.upload_encoded("tests/test_photo2.jpg", - title=self.TEST_TITLE) + # Re-upload the photos, one of them using Bas64 encoding + ret_val = self.client.photo.upload("tests/test_photo1.jpg", + title=self.TEST_TITLE) + self.client.photo.upload("tests/test_photo2.jpg", + title=self.TEST_TITLE) self.client.photo.upload_encoded("tests/test_photo3.jpg", title=self.TEST_TITLE) @@ -56,8 +56,8 @@ class TestPhotos(test_base.TestBase): """ Ensure that duplicate photos are rejected """ # Attempt to upload a duplicate with self.assertRaises(openphoto.OpenPhotoDuplicateError): - self.client.photo.upload_encoded("tests/test_photo1.jpg", - title=self.TEST_TITLE) + self.client.photo.upload("tests/test_photo1.jpg", + title=self.TEST_TITLE) # Check there are still three photos self.photos = self.client.photos.list() @@ -141,11 +141,6 @@ class TestPhotos(test_base.TestBase): with self.assertRaises(openphoto.NotImplementedError): self.client.photo.replace_encoded(None, None) - def test_upload(self): - """ If photo.upload gets implemented, write a test! """ - with self.assertRaises(openphoto.NotImplementedError): - self.client.photo.upload(None) - def test_dynamic_url(self): """ If photo.dynamic_url gets implemented, write a test! """ with self.assertRaises(openphoto.NotImplementedError): From b8f893089a35627305b2a6dd1f6ba27268f8e865 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 17 Mar 2013 18:11:08 +0000 Subject: [PATCH 29/31] Expand "~" to home path --- openphoto/multipart_post.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openphoto/multipart_post.py b/openphoto/multipart_post.py index 14c38ba..e53fb38 100644 --- a/openphoto/multipart_post.py +++ b/openphoto/multipart_post.py @@ -1,3 +1,4 @@ +import os import mimetypes import mimetools @@ -20,7 +21,7 @@ def encode_multipart_formdata(params, files): lines.append("Content-Disposition: form-data; name=\"%s\"; filename=\"%s\"" % (name, filename)) lines.append("Content-Type: %s" % content_type) lines.append("") - lines.append(open(filename, "rb").read()) + lines.append(open(os.path.expanduser(filename), "rb").read()) lines.append("--" + boundary + "--") lines.append("") From 3f5c0cca4945cf8d23131a6fba6905d477c83d32 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 17 Mar 2013 18:14:54 +0000 Subject: [PATCH 30/31] Add file upload support to the commandline, using the same technique as openphoto-php --- openphoto/main.py | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/openphoto/main.py b/openphoto/main.py index e561da5..9339708 100644 --- a/openphoto/main.py +++ b/openphoto/main.py @@ -47,7 +47,8 @@ def main(args=sys.argv[1:]): if options.method == "GET": result = client.get(options.endpoint, process_response=False, **params) else: - result = client.post(options.endpoint, process_response=False, **params) + params, files = extract_files(params) + result = client.post(options.endpoint, process_response=False, files=files, **params) if options.verbose: print "==========\nMethod: %s\nHost: %s\nEndpoint: %s" % (options.method, options.host, options.endpoint) @@ -62,5 +63,24 @@ def main(args=sys.argv[1:]): else: print result +def extract_files(params): + """ + Extract filenames from the "photo" parameter, so they can be uploaded, returning (updated_params, files). + Uses the same technique as openphoto-php: + * Filename can only be in the "photo" parameter + * Filename must be prefixed with "@" + * Filename must exist + ...otherwise the parameter is not extracted + """ + files = {} + updated_params = {} + for name in params: + if name == "photo" and params[name].startswith("@") and os.path.isfile(os.path.expanduser(params[name][1:])): + files[name] = params[name][1:] + else: + updated_params[name] = params[name] + + return updated_params, files + if __name__ == "__main__": main() From da8fbb52cfe8c4afe2514239914adb89ee4fe7c1 Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 24 Mar 2013 17:18:43 +0000 Subject: [PATCH 31/31] Updated to use Python logging module --- openphoto/__init__.py | 6 ++---- openphoto/openphoto_http.py | 29 ++++++++++++++--------------- tests/test_base.py | 17 +++++++++++++---- 3 files changed, 29 insertions(+), 23 deletions(-) diff --git a/openphoto/__init__.py b/openphoto/__init__.py index 9de6672..7e497bd 100644 --- a/openphoto/__init__.py +++ b/openphoto/__init__.py @@ -8,12 +8,10 @@ class OpenPhoto(OpenPhotoHttp): """ Client library for OpenPhoto """ def __init__(self, host, consumer_key='', consumer_secret='', - token='', token_secret='', - log_filename=None): + token='', token_secret=''): OpenPhotoHttp.__init__(self, host, consumer_key, consumer_secret, - token, token_secret, - log_filename) + token, token_secret) self.photos = api_photo.ApiPhotos(self) self.photo = api_photo.ApiPhoto(self) diff --git a/openphoto/openphoto_http.py b/openphoto/openphoto_http.py index cfc0348..f020fda 100644 --- a/openphoto/openphoto_http.py +++ b/openphoto/openphoto_http.py @@ -2,6 +2,7 @@ import oauth2 as oauth import urlparse import urllib import httplib2 +import logging try: import json except ImportError: @@ -16,16 +17,14 @@ DUPLICATE_RESPONSE = {"code": 409, class OpenPhotoHttp: """ Base class to handle HTTP requests to an OpenPhoto server """ def __init__(self, host, consumer_key='', consumer_secret='', - token='', token_secret='', log_filename=None): + token='', token_secret=''): self._host = host self._consumer_key = consumer_key self._consumer_secret = consumer_secret self._token = token self._token_secret = token_secret - self._logfile = None - if log_filename: - self._logfile = open(log_filename, "w") + self._logger = logging.getLogger("openphoto") # Remember the most recent HTTP request and response self.last_url = None @@ -52,11 +51,10 @@ class OpenPhotoHttp: _, content = client.request(url, "GET") - if self._logfile: - print >> self._logfile, "----------------------------" - print >> self._logfile, "GET %s" % url - print >> self._logfile, "----------------------------" - print >> self._logfile, content + self._logger.info("============================") + self._logger.info("GET %s" % url) + self._logger.info("---") + self._logger.info(content) self.last_url = url self.last_params = params @@ -90,12 +88,13 @@ class OpenPhotoHttp: _, content = client.request(url, "POST", body) - if self._logfile: - print >> self._logfile, "----------------------------" - print >> self._logfile, "POST %s" % url - print >> self._logfile, body - print >> self._logfile, "----------------------------" - print >> self._logfile, content + # TODO: Don't log file data in multipart forms + self._logger.info("============================") + self._logger.info("POST %s" % url) + if body: + self._logger.info(body) + self._logger.info("---") + self._logger.info(content) self.last_url = url self.last_params = params diff --git a/tests/test_base.py b/tests/test_base.py index 58bc7e6..3d472d4 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -1,4 +1,5 @@ import unittest +import logging import openphoto try: @@ -16,8 +17,6 @@ except ImportError: "********************************************************************\n") raise -LOG_FILENAME = "tests.log" - class TestBase(unittest.TestCase): TEST_TITLE = "Test Image - delete me!" TEST_TAG = "test_tag" @@ -28,13 +27,18 @@ class TestBase(unittest.TestCase): unittest.TestCase.__init__(self, *args, **kwds) self.photos = [] + LOG_FILENAME = "tests.log" + logging.basicConfig(filename="tests.log", + filemode="w", + format="%(message)s", + level=logging.INFO) + @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, - log_filename=LOG_FILENAME) + tokens.token, tokens.token_secret) if cls.client.photos.list() != []: raise ValueError("The test server (%s) contains photos. " @@ -97,6 +101,11 @@ class TestBase(unittest.TestCase): print "Albums: %s" % self.albums raise Exception("Album creation failed") + logging.info("\nRunning %s..." % self.id()) + + def tearDown(self): + logging.info("Finished %s\n" % self.id()) + @classmethod def _create_test_photos(cls): """ Upload three test photos """