Skip to content

Commit 4e06d5b

Browse files
committed
feat(media): ✨ add media upload api for v2
1 parent 189a163 commit 4e06d5b

File tree

3 files changed

+240
-23
lines changed

3 files changed

+240
-23
lines changed

pytwitter/api.py

Lines changed: 219 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,32 @@ def _parse_response(resp: Response) -> dict:
410410

411411
return data
412412

413+
@staticmethod
414+
def _format_response(resp_json, cls, multi=False) -> md.Response:
415+
data, includes, meta, errors = (
416+
resp_json.get("data", []),
417+
resp_json.get("includes"),
418+
resp_json.get("meta"),
419+
resp_json.get("errors"),
420+
)
421+
if multi:
422+
data = [cls.new_from_json_dict(item) for item in data]
423+
else:
424+
data = cls.new_from_json_dict(data)
425+
426+
res = md.Response(
427+
data=data,
428+
includes=md.Includes.new_from_json_dict(includes),
429+
meta=md.Meta.new_from_json_dict(meta),
430+
errors=(
431+
[md.Error.new_from_json_dict(err) for err in errors]
432+
if errors is not None
433+
else None
434+
),
435+
_json=resp_json,
436+
)
437+
return res
438+
413439
def _get(
414440
self,
415441
url: str,
@@ -434,29 +460,7 @@ def _get(
434460
if return_json:
435461
return resp_json
436462
else:
437-
data, includes, meta, errors = (
438-
resp_json.get("data", []),
439-
resp_json.get("includes"),
440-
resp_json.get("meta"),
441-
resp_json.get("errors"),
442-
)
443-
if multi:
444-
data = [cls.new_from_json_dict(item) for item in data]
445-
else:
446-
data = cls.new_from_json_dict(data)
447-
448-
res = md.Response(
449-
data=data,
450-
includes=md.Includes.new_from_json_dict(includes),
451-
meta=md.Meta.new_from_json_dict(meta),
452-
errors=(
453-
[md.Error.new_from_json_dict(err) for err in errors]
454-
if errors is not None
455-
else None
456-
),
457-
_json=resp_json,
458-
)
459-
return res
463+
return self._format_response(resp_json, cls, multi)
460464

461465
def get_tweets(
462466
self,
@@ -762,6 +766,198 @@ def upload_media_chunked_status(
762766
else:
763767
return md.MediaUploadResponse.new_from_json_dict(data=data)
764768

769+
def upload_media_simple_v2(
770+
self,
771+
media: Optional[IO] = None,
772+
media_category: Optional[str] = None,
773+
additional_owners: Optional[List[str]] = None,
774+
return_json: bool = False,
775+
) -> Union[dict, md.MediaUpload]:
776+
"""
777+
Simple Upload, Use this endpoint to upload images to Twitter.
778+
779+
Note: The simple upload endpoint can only be used to upload images.
780+
781+
:param media: The raw binary file content being uploaded.
782+
:param media_category: The category that represents how the media will be used.
783+
This field is required when using the media with the Ads API.
784+
Possible values:
785+
- tweet_image
786+
- tweet_gif
787+
- tweet_video
788+
- amplify_video
789+
:param additional_owners: A comma-separated list of user IDs to set as additional owners
790+
allowed to use the returned media_id in Tweets or Cards.
791+
Up to 100 additional owners may be specified.
792+
:param return_json: Type for returned data. If you set True JSON data will be returned.
793+
:return: Media upload response.
794+
"""
795+
796+
files, args = {}, {}
797+
if media:
798+
files["media"] = media
799+
else:
800+
raise PyTwitterError("Need media or media_data")
801+
if media_category:
802+
args["media_category"] = media_category
803+
if additional_owners:
804+
args["additional_owners"] = enf_comma_separated(
805+
name="additional_owners", value=additional_owners
806+
)
807+
808+
resp = self._request(
809+
url=f"{self.BASE_URL_V2}/media/upload",
810+
verb="POST",
811+
data=args,
812+
files=files,
813+
)
814+
data = self._parse_response(resp=resp)
815+
if return_json:
816+
return data
817+
else:
818+
return md.MediaUpload.new_from_json_dict(data=data)
819+
820+
def upload_media_chunked_init_v2(
821+
self,
822+
total_bytes: int,
823+
media_type: str,
824+
media_category: Optional[str] = None,
825+
additional_owners: Optional[List[str]] = None,
826+
return_json: bool = False,
827+
) -> Union[dict, md.Response]:
828+
"""
829+
Chunked Upload, Use this endpoint to upload videos and images to Twitter.
830+
831+
Note: The chunked upload endpoint can be used to upload both images and videos.
832+
Videos must be sent as chunked media containers, which means that you must send the
833+
raw chunked media data and the media category separately.
834+
835+
:param total_bytes: The total size of the media being uploaded in bytes.
836+
:param media_type: The MIME type of the media being uploaded. example: image/jpeg, image/gif, and video/mp4.
837+
:param media_category: The category that represents how the media will be used.
838+
This field is required when using the media with the Ads API.
839+
Possible values:
840+
- tweet_image
841+
- tweet_gif
842+
- tweet_video
843+
- amplify_video
844+
- dm_video
845+
- subtitles
846+
:param additional_owners: A comma-separated list of user IDs to set as additional owners
847+
allowed to use the returned media_id in Tweets or Cards.
848+
Up to 100 additional owners may be specified.
849+
:param return_json: Type for returned data. If you set True JSON data will be returned.
850+
:return: Media upload response.
851+
"""
852+
853+
args = {
854+
"command": "INIT",
855+
"total_bytes": total_bytes,
856+
"media_type": media_type,
857+
}
858+
if media_category:
859+
args["media_category"] = media_category
860+
if additional_owners:
861+
args["additional_owners"] = enf_comma_separated(
862+
name="additional_owners", value=additional_owners
863+
)
864+
865+
resp = self._request(
866+
url=f"{self.BASE_URL_V2}/media/upload",
867+
verb="POST",
868+
data=args,
869+
)
870+
data = self._parse_response(resp=resp)
871+
if return_json:
872+
return data
873+
else:
874+
return self._format_response(resp_json=data, cls=md.MediaUpload)
875+
876+
def upload_media_chunked_append_v2(
877+
self,
878+
media_id: str,
879+
segment_index: int,
880+
media: Optional[IO],
881+
) -> bool:
882+
"""
883+
Used to upload a chunk (consecutive byte range) of the media file.
884+
885+
:param media_id: The `media_id` returned from the INIT step.
886+
:param segment_index: An ordered index of file chunk. It must be between 0-999 inclusive.
887+
The first segment has index 0, second segment has index 1, and so on.
888+
:param media: The raw binary file content being uploaded. Cannot be used with `media_data`.
889+
:return: True if upload success.
890+
"""
891+
resp = self._request(
892+
url=f"{self.BASE_URL_V2}/media/upload",
893+
verb="POST",
894+
params={
895+
"command": "APPEND",
896+
"media_id": media_id,
897+
},
898+
data={"segment_index": segment_index},
899+
files={"media": media},
900+
)
901+
if resp.ok:
902+
return True
903+
raise PyTwitterError(resp.json())
904+
905+
def upload_media_chunked_finalize_v2(
906+
self,
907+
media_id: str,
908+
return_json: bool = False,
909+
) -> Union[dict, md.Response]:
910+
"""
911+
Check the status of the chunk upload.
912+
913+
Note: Can only call after the FINALIZE step. If chunked upload not sync mode will return error.
914+
915+
:param media_id: The `media_id` returned from the INIT step.
916+
:param return_json: Type for returned data. If you set True JSON data will be returned.
917+
:return: Media upload response.
918+
"""
919+
resp = self._request(
920+
url=f"{self.BASE_URL_V2}/media/upload",
921+
verb="POST",
922+
params={
923+
"command": "FINALIZE",
924+
"media_id": media_id,
925+
},
926+
)
927+
data = self._parse_response(resp=resp)
928+
if return_json:
929+
return data
930+
else:
931+
return self._format_response(resp_json=data, cls=md.MediaUpload)
932+
933+
def upload_media_chunked_status_v2(
934+
self,
935+
media_id: str,
936+
return_json: bool = False,
937+
) -> Union[dict, md.Response]:
938+
"""
939+
Check the status of the chunk upload.
940+
941+
Note: Can only call after the FINALIZE step. If chunked upload not sync mode will return error.
942+
943+
:param media_id: The `media_id` returned from the INIT step.
944+
:param return_json: Type for returned data. If you set True JSON data will be returned.
945+
:return: Media upload response.
946+
"""
947+
resp = self._request(
948+
url=f"{self.BASE_URL_V2}/media/upload",
949+
verb="GET",
950+
params={
951+
"command": "STATUS",
952+
"media_id": media_id,
953+
},
954+
)
955+
data = self._parse_response(resp=resp)
956+
if return_json:
957+
return data
958+
else:
959+
return self._format_response(resp_json=data, cls=md.MediaUpload)
960+
765961
def create_tweet(
766962
self,
767963
*,

pytwitter/models/ext.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from . import (
1111
BaseModel,
1212
Media,
13+
MediaUpload,
1314
Place,
1415
Poll,
1516
Tweet,
@@ -107,6 +108,7 @@ class Response:
107108
User,
108109
Tweet,
109110
Media,
111+
MediaUpload,
110112
Poll,
111113
Place,
112114
Space,

pytwitter/models/media_upload.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,22 @@ class MediaUploadResponse(BaseModel):
6767
processing_info: Optional[MediaUploadResponseProcessingInfo] = field(default=None)
6868
image: Optional[MediaUploadResponseImage] = field(default=None)
6969
video: Optional[MediaUploadResponseVideo] = field(default=None)
70+
71+
72+
@dataclass
73+
class MediaUploadImage(MediaUploadResponseImage): ...
74+
75+
76+
@dataclass
77+
class MediaUploadVideo(MediaUploadResponseVideo): ...
78+
79+
80+
@dataclass
81+
class MediaUpload(BaseModel):
82+
id: Optional[str] = field(default=None)
83+
media_key: Optional[str] = field(default=None)
84+
expires_after_secs: Optional[int] = field(default=None, repr=False)
85+
processing_info: Optional[MediaUploadResponseProcessingInfo] = field(default=None)
86+
size: Optional[int] = field(default=None, repr=False)
87+
image: Optional[MediaUploadImage] = field(default=None)
88+
video: Optional[MediaUploadVideo] = field(default=None)

0 commit comments

Comments
 (0)