From 682a2ac85d8fba3df39e20cd5a7de2672658f5c6 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 14:57:39 +0000 Subject: [PATCH 01/13] 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/13] 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/13] 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 8d628b423a07e60deb84917d78a22e541a0f12a9 Mon Sep 17 00:00:00 2001 From: Pete Date: Sat, 9 Feb 2013 15:35:02 +0000 Subject: [PATCH 04/13] 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 05/13] 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 06/13] 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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 13/13] 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 """