From 682a2ac85d8fba3df39e20cd5a7de2672658f5c6 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 14:57:39 +0000 Subject: [PATCH 01/19] 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 02/19] 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 03/19] 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 04/19] 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 05/19] 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 06/19] 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 07/19] 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 08/19] 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 09/19] 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 10/19] 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 11/19] 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 12/19] 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 13/19] 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 14/19] 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 15/19] 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 16/19] 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 17/19] 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 18/19] 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 """ From e09f1667435370ef7f158768d0e331bb276510c2 Mon Sep 17 00:00:00 2001 From: Pete Date: Tue, 9 Apr 2013 18:18:12 +0100 Subject: [PATCH 19/19] Update delete endpoints to return True if successful. If the operation isn't successful, the API returns an error code, which raises an OpenPhotoError exception. --- openphoto/api_album.py | 10 +++++++--- openphoto/api_photo.py | 23 ++++++++++++++++++----- openphoto/api_tag.py | 8 ++++++-- openphoto/objects.py | 27 +++++++++++++++++++++------ tests/test_albums.py | 4 ++-- tests/test_photos.py | 8 ++++---- tests/test_tags.py | 4 ++-- 7 files changed, 60 insertions(+), 24 deletions(-) diff --git a/openphoto/api_album.py b/openphoto/api_album.py index e997b65..f2f2644 100644 --- a/openphoto/api_album.py +++ b/openphoto/api_album.py @@ -20,11 +20,15 @@ class ApiAlbum: return Album(self._client, result) def delete(self, album, **kwds): - """ Delete an album """ + """ + Delete an album. + Returns True if successful. + Raises an OpenPhotoError if not. + """ if not isinstance(album, Album): album = Album(self._client, {"id": album}) - album.delete(**kwds) - + return album.delete(**kwds) + def form(self, album, **kwds): raise NotImplementedError() diff --git a/openphoto/api_photo.py b/openphoto/api_photo.py index fed9c8a..2c18e8e 100644 --- a/openphoto/api_photo.py +++ b/openphoto/api_photo.py @@ -14,25 +14,38 @@ class ApiPhotos: return [Photo(self._client, photo) for photo in photos] def update(self, photos, **kwds): - """ Updates a list of photos """ + """ + Updates a list of photos. + Returns True if successful. + Raises OpenPhotoError if not. + """ if not self._client.post("/photos/update.json", ids=photos, **kwds)["result"]: raise OpenPhotoError("Update response returned False") + return True def delete(self, photos, **kwds): - """ Deletes a list of photos """ + """ + Deletes a list of photos. + Returns True if successful. + Raises OpenPhotoError if not. + """ if not self._client.post("/photos/delete.json", ids=photos, **kwds)["result"]: raise OpenPhotoError("Delete response returned False") - + return True class ApiPhoto: def __init__(self, client): self._client = client def delete(self, photo, **kwds): - """ Delete a photo """ + """ + Delete a photo. + Returns True if successful. + Raises an OpenPhotoError if not. + """ if not isinstance(photo, Photo): photo = Photo(self._client, {"id": photo}) - photo.delete(**kwds) + return photo.delete(**kwds) def edit(self, photo, **kwds): """ Returns an HTML form to edit a photo """ diff --git a/openphoto/api_tag.py b/openphoto/api_tag.py index 89f9fee..5f3c676 100644 --- a/openphoto/api_tag.py +++ b/openphoto/api_tag.py @@ -20,10 +20,14 @@ class ApiTag: return Tag(self._client, result) def delete(self, tag, **kwds): - """ Delete a tag """ + """ + Delete a tag. + Returns True if successful. + Raises an OpenPhotoError if not. + """ if not isinstance(tag, Tag): tag = Tag(self._client, {"id": tag}) - tag.delete(**kwds) + return tag.delete(**kwds) def update(self, tag, **kwds): """ Update a tag """ diff --git a/openphoto/objects.py b/openphoto/objects.py index 965df9a..d85a780 100644 --- a/openphoto/objects.py +++ b/openphoto/objects.py @@ -39,9 +39,14 @@ class OpenPhotoObject: class Photo(OpenPhotoObject): def delete(self, **kwds): - """ Delete this photo """ - self._openphoto.post("/photo/%s/delete.json" % self.id, **kwds) + """ + Delete this photo. + Returns True if successful. + Raises an OpenPhotoError if not. + """ + result = self._openphoto.post("/photo/%s/delete.json" % self.id, **kwds)["result"] self._replace_fields({}) + return result def edit(self, **kwds): """ Returns an HTML form to edit the photo """ @@ -97,9 +102,14 @@ class Photo(OpenPhotoObject): class Tag(OpenPhotoObject): def delete(self, **kwds): - """ Delete this tag """ - self._openphoto.post("/tag/%s/delete.json" % self.id, **kwds) + """ + Delete this tag. + Returns True if successful. + Raises an OpenPhotoError if not. + """ + result = self._openphoto.post("/tag/%s/delete.json" % self.id, **kwds)["result"] self._replace_fields({}) + return result def update(self, **kwds): """ Update this tag with the specified parameters """ @@ -125,9 +135,14 @@ class Album(OpenPhotoObject): self.photos[i] = Photo(self._openphoto, photo) def delete(self, **kwds): - """ Delete this album """ - self._openphoto.post("/album/%s/delete.json" % self.id, **kwds) + """ + Delete this album. + Returns True if successful. + Raises an OpenPhotoError if not. + """ + result = self._openphoto.post("/album/%s/delete.json" % self.id, **kwds)["result"] self._replace_fields({}) + return result def form(self, **kwds): raise NotImplementedError() diff --git a/tests/test_albums.py b/tests/test_albums.py index 53ff7e9..563660f 100644 --- a/tests/test_albums.py +++ b/tests/test_albums.py @@ -15,13 +15,13 @@ class TestAlbums(test_base.TestBase): self.assertIn(album_name, [a.name for a in self.client.albums.list()]) # Delete the album - self.client.album.delete(album.id) + self.assertTrue(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) - album.delete() + self.assertTrue(album.delete()) # Check that the album is now gone self.assertNotIn(album_name, [a.name for a in self.client.albums.list()]) diff --git a/tests/test_photos.py b/tests/test_photos.py index ffddbfe..04e216d 100644 --- a/tests/test_photos.py +++ b/tests/test_photos.py @@ -6,11 +6,11 @@ class TestPhotos(test_base.TestBase): def test_delete_upload(self): """ Test photo deletion and upload """ # Delete one photo using the OpenPhoto class, passing in the id - self.client.photo.delete(self.photos[0].id) + self.assertTrue(self.client.photo.delete(self.photos[0].id)) # Delete one photo using the OpenPhoto class, passing in the object - self.client.photo.delete(self.photos[1]) + self.assertTrue(self.client.photo.delete(self.photos[1])) # And another using the Photo object directly - self.photos[2].delete() + self.assertTrue(self.photos[2].delete()) # Check that they're gone self.assertEqual(self.client.photos.list(), []) @@ -32,7 +32,7 @@ class TestPhotos(test_base.TestBase): self.assertIn(ret_val.pathOriginal, pathOriginals) # Delete all photos in one go - self.client.photos.delete(self.photos) + self.assertTrue(self.client.photos.delete(self.photos)) # Check they're gone self.photos = self.client.photos.list() diff --git a/tests/test_tags.py b/tests/test_tags.py index 02ea449..7f06ed2 100644 --- a/tests/test_tags.py +++ b/tests/test_tags.py @@ -16,13 +16,13 @@ class TestTags(test_base.TestBase): self.assertIn(tag_name, self.client.tags.list()) # Delete the tag - self.client.tag.delete(tag_name) + self.assertTrue(self.client.tag.delete(tag_name)) # Check that the tag is now gone self.assertNotIn(tag_name, self.client.tags.list()) # Create and delete using the Tag object directly tag = self.client.tag.create(tag_name) - tag.delete() + self.assertTrue(tag.delete()) # Check that the tag is now gone self.assertNotIn(tag_name, self.client.tags.list())