diff --git a/testdata/corgi.gif b/testdata/corgi.gif new file mode 100644 index 00000000..e74b7426 Binary files /dev/null and b/testdata/corgi.gif differ diff --git a/testdata/post_upload_chunked_FINAL.json b/testdata/post_upload_chunked_FINAL.json new file mode 100644 index 00000000..1e31a23e --- /dev/null +++ b/testdata/post_upload_chunked_FINAL.json @@ -0,0 +1 @@ +{"image": {"image_type": "image/gif", "h": 270, "w": 500}, "media_id_string": "737956420046356480", "media_id": 737956420046356480, "expires_after_secs": 86400, "size": 2158019} \ No newline at end of file diff --git a/testdata/post_upload_chunked_INIT.json b/testdata/post_upload_chunked_INIT.json new file mode 100644 index 00000000..f0b4c317 --- /dev/null +++ b/testdata/post_upload_chunked_INIT.json @@ -0,0 +1 @@ +{"media_id_string": "737956420046356480", "media_id": 737956420046356480, "expires_after_secs": 86400} \ No newline at end of file diff --git a/tests/test_api_30.py b/tests/test_api_30.py index 44d228b2..a4c6ddca 100644 --- a/tests/test_api_30.py +++ b/tests/test_api_30.py @@ -33,7 +33,8 @@ def setUp(self): consumer_secret='test', access_token_key='test', access_token_secret='test', - sleep_on_rate_limit=False) + sleep_on_rate_limit=False, + chunk_size=500*1024) self.base_url = 'https://api.twitter.com/1.1' self._stderr = sys.stderr sys.stderr = ErrNull() @@ -1674,3 +1675,37 @@ def testMuteBlockParamsAndErrors(self): resp = self.api.CreateMute(screen_name='jack', skip_status=True) self.assertTrue(isinstance(resp, twitter.User)) self.assertFalse(resp.status) + + @responses.activate + def testPostUploadMediaChunkedInit(self): + with open('testdata/post_upload_chunked_INIT.json') as f: + resp_data = f.read() + responses.add(responses.POST, DEFAULT_URL, body=resp_data, status=200) + + with open('testdata/corgi.gif', 'rb') as fp: + resp = self.api._UploadMediaChunkedInit(fp) + self.assertEqual(len(responses.calls), 1) + self.assertEqual(resp[0], 737956420046356480) + + @responses.activate + def testPostUploadMediaChunkedAppend(self): + media_fp, filename, _, _ = twitter.twitter_utils.parse_media_file( + 'testdata/corgi.gif') + responses.add(responses.POST, DEFAULT_URL, body='', status=200) + + resp = self.api._UploadMediaChunkedAppend(media_id=737956420046356480, + media_fp=media_fp, + filename=filename) + self.assertEqual(len(responses.calls), 7) + self.assertTrue(resp) + + @responses.activate + def testPostUploadMediaChunkedFinalize(self): + with open('testdata/post_upload_chunked_FINAL.json') as f: + resp_data = f.read() + + responses.add(responses.POST, DEFAULT_URL, body=resp_data, status=200) + + resp = self.api._UploadMediaChunkedFinalize(media_id=737956420046356480) + self.assertEqual(len(responses.calls), 1) + self.assertTrue(resp) diff --git a/twitter/api.py b/twitter/api.py index 186ec1d6..b63c6b8c 100644 --- a/twitter/api.py +++ b/twitter/api.py @@ -985,9 +985,8 @@ def PostUpdate(self, else: _, _, file_size, _ = parse_media_file(media) if file_size > self.chunk_size: - media_ids = self.UploadMediaChunked( - media, - media_additional_owners) + media_ids.append(self.UploadMediaChunked(media, + media_additional_owners)) else: media_ids.append( self.UploadMediaSimple(media, @@ -1079,11 +1078,11 @@ def PostMediaMetadata(self, return resp - def UploadMediaChunked(self, - media, - additional_owners=None, - media_category=None): - """ Upload a media file to Twitter in multiple requests. + def _UploadMediaChunkedInit(self, + media, + additional_owners=None, + media_category=None): + """Start a chunked upload to Twitter. Args: media: @@ -1096,8 +1095,8 @@ def UploadMediaChunked(self, API & video files. Returns: - media_id: - ID of the uploaded media returned by the Twitter API or 0. + tuple: media_id (returned from Twitter), file-handler object (i.e., has .read() + method), filename media file. """ url = '%s/media/upload.json' % self.upload_url @@ -1129,10 +1128,33 @@ def UploadMediaChunked(self, except KeyError: raise TwitterError({'message': 'Media could not be uploaded'}) - boundary = bytes("--{0}".format(uuid4()), 'utf-8') - media_id_bytes = bytes(str(media_id).encode('utf-8')) + return (media_id, media_fp, filename) + + + def _UploadMediaChunkedAppend(self, + media_id, + media_fp, + filename): + """Appends (i.e., actually uploads) media file to Twitter. + + Args: + media_id (int): + ID of the media file received from Init method. + media_fp (file): + File-like object representing media file (must have .read() method) + filename (str): + Filename of the media file being uploaded. + + Returns: + True if successful. Raises otherwise. + """ + url = '%s/media/upload.json' % self.upload_url + + boundary = "--{0}".format(uuid4().hex).encode('utf-8') + media_id_bytes = str(media_id).encode('utf-8') headers = {'Content-Type': 'multipart/form-data; boundary={0}'.format( - str(boundary[2:], 'utf-8'))} + boundary.decode('utf8')[2:] + )} segment_id = 0 while True: @@ -1154,9 +1176,9 @@ def UploadMediaChunked(self, boundary, b'Content-Disposition: form-data; name="segment_index"', b'', - bytes(str(segment_id).encode('utf-8')), + str(segment_id).encode('utf-8'), boundary, - bytes('Content-Disposition: form-data; name="media"; filename="{0}"'.format(filename), 'utf-8'), + 'Content-Disposition: form-data; name="media"; filename="{0}"'.format(filename).encode('utf8'), b'Content-Type: application/octet-stream', b'', data, @@ -1182,7 +1204,20 @@ def UploadMediaChunked(self, except: pass - # Finalizing the upload: + return True + + def _UploadMediaChunkedFinalize(self, media_id): + """Finalize chunked upload to Twitter. + + Args: + media_id (int): + ID of the media file for which to finalize the upload. + + Returns: + json: JSON string of data from Twitter. + """ + url = '%s/media/upload.json' % self.upload_url + parameters = { 'command': 'FINALIZE', 'media_id': media_id @@ -1191,10 +1226,49 @@ def UploadMediaChunked(self, resp = self._RequestUrl(url, 'POST', data=parameters) data = self._ParseAndCheckTwitter(resp.content.decode('utf-8')) + return data + + + def UploadMediaChunked(self, + media, + additional_owners=None, + media_category=None): + """Upload a media file to Twitter in multiple requests. + + Args: + media: + File-like object to upload. + additional_owners: additional Twitter users that are allowed to use + The uploaded media. Should be a list of integers. Maximum + number of additional owners is capped at 100 by Twitter. + media_category: + Category with which to identify media upload. Only use with Ads + API & video files. + + Returns: + media_id: + ID of the uploaded media returned by the Twitter API. Raises if + unsuccesful. + """ + + media_id, media_fp, filename = self._UploadMediaChunkedInit(media=media, + additional_owners=additional_owners, + media_category=media_category) + + append = self._UploadMediaChunkedAppend(media_id=media_id, + media_fp=media_fp, + filename=filename) + + if not append: + TwitterError('Media could not be uploaded.') + + data = self._UploadMediaChunkedFinalize(media_id) + try: return data['media_id'] except KeyError: - raise TwitterError({'message': 'Media could not be uploaded.'}) + raise TwitterError('Media could not be uploaded.') + def PostMedia(self, status,