From 5858043d44092cbf84c4619a882302f97f16671b Mon Sep 17 00:00:00 2001 From: sneakypete81 Date: Sun, 17 Mar 2013 15:08:09 +0000 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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()