]> jfr.im git - z_archive/twitter.git/blame - twitter/api.py
add doc for new twitter way to send medias (close #279 )
[z_archive/twitter.git] / twitter / api.py
CommitLineData
f0603331 1# encoding: utf-8
491792a2 2from __future__ import unicode_literals, print_function
f0603331 3
2b533cdc
MV
4from .util import PY_3_OR_HIGHER, actually_bytes
5
3930cc7b
MV
6try:
7 import urllib.request as urllib_request
8 import urllib.error as urllib_error
9except ImportError:
10 import urllib2 as urllib_request
11 import urllib2 as urllib_error
7364ea65 12
a403f1b3
JL
13try:
14 from cStringIO import StringIO
15except ImportError:
6204d0da 16 from io import BytesIO as StringIO
a403f1b3 17
14fc6b25
MG
18from .twitter_globals import POST_ACTIONS
19from .auth import NoAuth
4e9d6343 20
2ee65672 21import re
745061c1 22import sys
a403f1b3 23import gzip
491792a2 24from time import sleep, time
23dcf621 25
26try:
dea9a3e7 27 import http.client as http_client
23dcf621 28except ImportError:
dea9a3e7 29 import httplib as http_client
2ee65672 30
4b12a3a0 31try:
f1a8ed67 32 import json
4b12a3a0 33except ImportError:
f1a8ed67 34 import simplejson as json
35
fcf08b18 36
652c5402
MV
37class _DEFAULT(object):
38 pass
39
fcf08b18 40
5251ea48 41class TwitterError(Exception):
21e3bd23 42 """
64a8d213
B
43 Base Exception thrown by the Twitter object when there is a
44 general error interacting with the API.
21e3bd23 45 """
5251ea48 46 pass
47
fcf08b18 48
64a8d213
B
49class TwitterHTTPError(TwitterError):
50 """
51 Exception thrown by the Twitter object when there is an
52 HTTP error interacting with twitter.com.
53 """
1be4ce71 54 def __init__(self, e, uri, format, uriparts):
4b12a3a0
MV
55 self.e = e
56 self.uri = uri
57 self.format = format
58 self.uriparts = uriparts
7fe9aab6
HN
59 try:
60 data = self.e.fp.read()
dea9a3e7 61 except http_client.IncompleteRead as e:
7fe9aab6
HN
62 # can't read the error text
63 # let's try some of it
64 data = e.partial
e9fc8d86 65 if self.e.headers.get('Content-Encoding') == 'gzip':
7fe9aab6 66 buf = StringIO(data)
84d2da3d 67 f = gzip.GzipFile(fileobj=buf)
0c77ad3f
JS
68 data = f.read()
69 if len(data) == 0:
70 data = {}
71 elif "json" == self.format:
72 data = json.loads(data.decode('utf8'))
84d2da3d 73 else:
0c77ad3f
JS
74 data = data.decode('utf8')
75 self.response_data = data
9f04d75d 76 super(TwitterHTTPError, self).__init__(str(self))
64a8d213
B
77
78 def __str__(self):
57b54437 79 fmt = ("." + self.format) if self.format else ""
68b3e2ee 80 return (
57b54437 81 "Twitter sent status %i for URL: %s%s using parameters: "
fcf08b18 82 "(%s)\ndetails: %s" % (
57b54437 83 self.e.code, self.uri, fmt, self.uriparts,
c7dd86d1 84 self.response_data))
64a8d213 85
fcf08b18 86
84d0a294
MV
87class TwitterResponse(object):
88 """
89 Response from a twitter request. Behaves like a list or a string
90 (depending on requested format) but it has a few other interesting
91 attributes.
92
93 `headers` gives you access to the response headers as an
94 httplib.HTTPHeaders instance. You can do
ba02331e 95 `response.headers.get('h')` to retrieve a header.
84d0a294 96 """
84d0a294 97
84d0a294
MV
98 @property
99 def rate_limit_remaining(self):
100 """
101 Remaining requests in the current rate-limit.
102 """
eeec9b00
IA
103 return int(self.headers.get('X-Rate-Limit-Remaining', "0"))
104
105 @property
106 def rate_limit_limit(self):
107 """
c53558ad 108 The rate limit ceiling for that given request.
eeec9b00
IA
109 """
110 return int(self.headers.get('X-Rate-Limit-Limit', "0"))
84d0a294
MV
111
112 @property
113 def rate_limit_reset(self):
114 """
115 Time in UTC epoch seconds when the rate limit will reset.
116 """
eeec9b00 117 return int(self.headers.get('X-Rate-Limit-Reset', "0"))
84d0a294
MV
118
119
ed23f46c
MV
120class TwitterDictResponse(dict, TwitterResponse):
121 pass
12bba6ac 122
abddd419 123
ed23f46c
MV
124class TwitterListResponse(list, TwitterResponse):
125 pass
fcf08b18 126
94803fc9 127
ed23f46c
MV
128def wrap_response(response, headers):
129 response_typ = type(response)
130 if response_typ is dict:
131 res = TwitterDictResponse(response)
132 res.headers = headers
133 elif response_typ is list:
134 res = TwitterListResponse(response)
135 res.headers = headers
136 else:
137 res = response
138 return res
abddd419 139
2b986d8d
BB
140
141POST_ACTIONS_RE = re.compile('(' + '|'.join(POST_ACTIONS) + r')(/\d+)?$')
142
4e5c4880 143def method_for_uri(uri):
2b986d8d
BB
144 if POST_ACTIONS_RE.search(uri):
145 return "POST"
146 return "GET"
0d6c0646 147
8fccbe95
MV
148
149def build_uri(orig_uriparts, kwargs):
150 """
151 Build the URI from the original uriparts and kwargs. Modifies kwargs.
152 """
153 uriparts = []
154 for uripart in orig_uriparts:
155 # If this part matches a keyword argument (starting with _), use
156 # the supplied value. Otherwise, just use the part.
157 if uripart.startswith("_"):
158 part = (str(kwargs.pop(uripart, uripart)))
159 else:
160 part = uripart
161 uriparts.append(part)
162 uri = '/'.join(uriparts)
163
164 # If an id kwarg is present and there is no id to fill in in
165 # the list of uriparts, assume the id goes at the end.
166 id = kwargs.pop('id', None)
167 if id:
168 uri += "/%s" % (id)
169
170 return uri
171
172
7364ea65 173class TwitterCall(object):
dd648a25 174
6d508c66
BB
175 TWITTER_UNAVAILABLE_WAIT = 30 # delay after HTTP codes 502, 503 or 504
176
c8d451e8 177 def __init__(
fcf08b18 178 self, auth, format, domain, callable_cls, uri="",
491792a2 179 uriparts=None, secure=True, timeout=None, gzip=False, retry=False):
568331a9 180 self.auth = auth
a55e6a11 181 self.format = format
153dee29 182 self.domain = domain
dd648a25 183 self.callable_cls = callable_cls
7364ea65 184 self.uri = uri
b0dedfc0 185 self.uriparts = uriparts
9a148ed1 186 self.secure = secure
effd06bb 187 self.timeout = timeout
86318060 188 self.gzip = gzip
491792a2 189 self.retry = retry
fd2bc885 190
7364ea65 191 def __getattr__(self, k):
192 try:
193 return object.__getattr__(self, k)
194 except AttributeError:
e748eed8 195 def extend_call(arg):
196 return self.callable_cls(
197 auth=self.auth, format=self.format, domain=self.domain,
ff3ca197 198 callable_cls=self.callable_cls, timeout=self.timeout,
491792a2 199 secure=self.secure, gzip=self.gzip, retry=self.retry,
ff3ca197 200 uriparts=self.uriparts + (arg,))
e748eed8 201 if k == "_":
202 return extend_call
203 else:
204 return extend_call(k)
fd2bc885 205
7364ea65 206 def __call__(self, **kwargs):
8fccbe95
MV
207 kwargs = dict(kwargs)
208 uri = build_uri(self.uriparts, kwargs)
4e5c4880 209 method = kwargs.pop('_method', None) or method_for_uri(uri)
2b533cdc 210 domain = self.domain
612ececa 211
920528cd
MV
212 # If an _id kwarg is present, this is treated as id as a CGI
213 # param.
214 _id = kwargs.pop('_id', None)
215 if _id:
216 kwargs['id'] = _id
be5f32da 217
8fd7289d
IA
218 # If an _timeout is specified in kwargs, use it
219 _timeout = kwargs.pop('_timeout', None)
920528cd 220
568331a9
MH
221 secure_str = ''
222 if self.secure:
223 secure_str = 's'
6c527e72 224 dot = ""
1be4ce71 225 if self.format:
6c527e72 226 dot = "."
2b533cdc
MV
227 url_base = "http%s://%s/%s%s%s" % (
228 secure_str, domain, uri, dot, self.format)
568331a9 229
c1d973eb 230 # Check if argument tells whether img is already base64 encoded
759cdb65 231 b64_convert = not kwargs.pop("_base64", False)
c1d973eb
A
232 if b64_convert:
233 import base64
234
94fb8fab
R
235 # Catch media arguments to handle oauth query differently for multipart
236 media = None
759cdb65
MV
237 if 'media' in kwargs:
238 mediafield = 'media'
239 media = kwargs.pop('media')
2b533cdc 240 media_raw = True
759cdb65
MV
241 elif 'media[]' in kwargs:
242 mediafield = 'media[]'
243 media = kwargs.pop('media[]')
244 if b64_convert:
245 media = base64.b64encode(media)
2b533cdc 246 media_raw = False
94fb8fab 247
c1d973eb
A
248 # Catch media arguments that are not accepted through multipart
249 # and are not yet base64 encoded
250 if b64_convert:
0e197382 251 for arg in ['banner', 'image']:
c1d973eb
A
252 if arg in kwargs:
253 kwargs[arg] = base64.b64encode(kwargs[arg])
254
86318060 255 headers = {'Accept-Encoding': 'gzip'} if self.gzip else dict()
1ff50236 256 body = None
257 arg_data = None
1be4ce71 258 if self.auth:
568331a9 259 headers.update(self.auth.generate_headers())
94fb8fab
R
260 # Use urlencoded oauth args with no params when sending media
261 # via multipart and send it directly via uri even for post
1ff50236 262 arg_data = self.auth.encode_params(
2b533cdc 263 url_base, method, {} if media else kwargs)
94fb8fab 264 if method == 'GET' or media:
2b533cdc 265 url_base += '?' + arg_data
1be4ce71 266 else:
2b533cdc 267 body = arg_data.encode('utf-8')
c53558ad 268
94fb8fab
R
269 # Handle query as multipart when sending media
270 if media:
759cdb65 271 BOUNDARY = b"###Python-Twitter###"
94fb8fab 272 bod = []
759cdb65 273 bod.append(b'--' + BOUNDARY)
1ff50236 274 bod.append(
2b533cdc
MV
275 b'Content-Disposition: form-data; name="'
276 + actually_bytes(mediafield)
277 + b'"')
278 bod.append(b'Content-Type: application/octet-stream')
279 if not media_raw:
280 bod.append(b'Content-Transfer-Encoding: base64')
759cdb65 281 bod.append(b'')
2b533cdc 282 bod.append(actually_bytes(media))
94fb8fab 283 for k, v in kwargs.items():
2b533cdc
MV
284 k = actually_bytes(k)
285 v = actually_bytes(v)
759cdb65 286 bod.append(b'--' + BOUNDARY)
2b533cdc
MV
287 bod.append(b'Content-Disposition: form-data; name="' + k + b'"')
288 bod.append(b'Content-Type: text/plain;charset=utf-8')
759cdb65 289 bod.append(b'')
94fb8fab 290 bod.append(v)
759cdb65 291 bod.append(b'--' + BOUNDARY + b'--')
2b533cdc
MV
292 bod.append(b'')
293 bod.append(b'')
759cdb65 294 body = b'\r\n'.join(bod)
2b533cdc 295 # print(body.decode('utf-8', errors='ignore'))
6551068a 296 headers['Content-Type'] = \
2b533cdc
MV
297 b'multipart/form-data; boundary=' + BOUNDARY
298
2b533cdc
MV
299 if not PY_3_OR_HIGHER:
300 url_base = url_base.encode("utf-8")
6551068a
R
301 for k in headers:
302 headers[actually_bytes(k)] = actually_bytes(headers.pop(k))
f5ca09ad 303
2b533cdc 304 req = urllib_request.Request(url_base, data=body, headers=headers)
491792a2
BB
305 if self.retry:
306 return self._handle_response_with_retry(req, uri, arg_data, _timeout)
307 else:
308 return self._handle_response(req, uri, arg_data, _timeout)
102acdb1 309
8fd7289d 310 def _handle_response(self, req, uri, arg_data, _timeout=None):
a5aab114 311 kwargs = {}
8fd7289d
IA
312 if _timeout:
313 kwargs['timeout'] = _timeout
7364ea65 314 try:
a5aab114 315 handle = urllib_request.urlopen(req, **kwargs)
918b8b48
GC
316 if handle.headers['Content-Type'] in ['image/jpeg', 'image/png']:
317 return handle
0fdfdc3d
DM
318 try:
319 data = handle.read()
dea9a3e7 320 except http_client.IncompleteRead as e:
0fdfdc3d
DM
321 # Even if we don't get all the bytes we should have there
322 # may be a complete response in e.partial
323 data = e.partial
324 if handle.info().get('Content-Encoding') == 'gzip':
a403f1b3 325 # Handle gzip decompression
0fdfdc3d 326 buf = StringIO(data)
a403f1b3
JL
327 f = gzip.GzipFile(fileobj=buf)
328 data = f.read()
c1d973eb
A
329 if len(data) == 0:
330 return wrap_response({}, handle.headers)
331 elif "json" == self.format:
a403f1b3 332 res = json.loads(data.decode('utf8'))
abddd419 333 return wrap_response(res, handle.headers)
de072195 334 else:
456ec92b 335 return wrap_response(
a403f1b3 336 data.decode('utf8'), handle.headers)
3930cc7b 337 except urllib_error.HTTPError as e:
de072195 338 if (e.code == 304):
7364ea65 339 return []
de072195 340 else:
aec68959 341 raise TwitterHTTPError(e, uri, self.format, arg_data)
102acdb1 342
491792a2 343 def _handle_response_with_retry(self, req, uri, arg_data, _timeout=None):
73a242d6
BB
344 retry = self.retry
345 while retry:
491792a2
BB
346 try:
347 return self._handle_response(req, uri, arg_data, _timeout)
348 except TwitterHTTPError as e:
349 if e.e.code == 429:
350 # API rate limit reached
351 reset = int(e.e.headers.get('X-Rate-Limit-Reset', time() + 30))
352 delay = int(reset - time() + 2) # add some extra margin
353 print("API rate limit reached; waiting for %ds..." % delay, file=sys.stderr)
354 elif e.e.code in (502, 503, 504):
6d508c66 355 delay = self.TWITTER_UNAVAILABLE_WAIT
491792a2
BB
356 print("Service unavailable; waiting for %ds..." % delay, file=sys.stderr)
357 else:
358 raise
19e3d13d 359 if isinstance(retry, int) and not isinstance(retry, bool):
73a242d6
BB
360 if retry <= 0:
361 raise
362 retry -= 1
491792a2
BB
363 sleep(delay)
364
fcf08b18 365
7364ea65 366class Twitter(TwitterCall):
367 """
368 The minimalist yet fully featured Twitter API class.
4e9d6343 369
7364ea65 370 Get RESTful data by accessing members of this class. The result
371 is decoded python objects (lists and dicts).
372
51e0b8f1 373 The Twitter API is documented at:
153dee29 374
aec68959
MV
375 http://dev.twitter.com/doc
376
4e9d6343 377
7364ea65 378 Examples::
4e9d6343 379
d4f3123e
MV
380 from twitter import *
381
d09c0dd3 382 t = Twitter(
4d37729a 383 auth=OAuth(token, token_key, con_secret, con_secret_key))
4e9d6343 384
58ccea4e
MV
385 # Get your "home" timeline
386 t.statuses.home_timeline()
4e9d6343 387
d4f3123e
MV
388 # Get a particular friend's timeline
389 t.statuses.user_timeline(screen_name="billybob")
390
391 # to pass in GET/POST parameters, such as `count`
392 t.statuses.home_timeline(count=5)
393
394 # to pass in the GET/POST parameter `id` you need to use `_id`
395 t.statuses.oembed(_id=1234567890)
d09c0dd3
MV
396
397 # Update your status
398 t.statuses.update(
399 status="Using @sixohsix's sweet Python Twitter Tools.")
4e9d6343 400
51e0b8f1 401 # Send a direct message
d09c0dd3 402 t.direct_messages.new(
51e0b8f1
MV
403 user="billybob",
404 text="I think yer swell!")
7364ea65 405
d09c0dd3 406 # Get the members of tamtar's list "Things That Are Rad"
22379712 407 t.lists.members(owner_screen_name="tamtar", slug="things-that-are-rad")
be5f32da 408
8fd7289d 409 # An *optional* `_timeout` parameter can also be used for API
a5aab114 410 # calls which take much more time than normal or twitter stops
d4f3123e 411 # responding for some reason:
a5aab114
IA
412 t.users.lookup(
413 screen_name=','.join(A_LIST_OF_100_SCREEN_NAMES), \
8fd7289d 414 _timeout=1)
a5aab114 415
5a412b39
R
416 # Overriding Method: GET/POST
417 # you should not need to use this method as this library properly
418 # detects whether GET or POST should be used, Nevertheless
419 # to force a particular method, use `_method`
420 t.statuses.oembed(_id=1234567890, _method='GET')
421
cd830ea5
R
422 # Send images along with your tweets:
423 # - first just read images from the web or from files the regular way:
5a412b39 424 with open("example.png", "rb") as imagefile:
cd830ea5
R
425 imagedata = imagefile.read()
426 # - then upload medias one by one on Twitter's dedicated server
427 # and collect each one's id:
428 t_up = Twitter(domain='upload.twitter.com',
429 auth=OAuth(token, token_key, con_secret, con_secret_key))
430 id_img1 = str(t_up.media.upload(media=imagedata)["media_id"])
431 id_img2 = str(t_up.media.upload(media=imagedata)["media_id"])
d4f3123e 432
cd830ea5
R
433 # - finally send your tweet with the list of media ids:
434 t.statuses.update(status="PTT ★", media_ids=",".join([id_img1, id_img2]))
435
436 # Or send a tweet with an image (or set a logo/banner similarily)
437 # using the old deprecated method that will probably disappear some day
438 params = {"media[]": imagedata, "status": "PTT ★"}
439 # Or for an image encoded as base64:
440 params = {"media[]": base64_image, "status": "PTT ★", "_base64": True}
5a412b39
R
441 t.statuses.update_with_media(**params)
442
b0dedfc0 443
cd830ea5 444
153dee29 445 Searching Twitter::
4e9d6343 446
58ccea4e
MV
447 # Search for the latest tweets about #pycon
448 t.search.tweets(q="#pycon")
153dee29 449
7364ea65 450
68b3e2ee
MV
451 Using the data returned
452 -----------------------
453
454 Twitter API calls return decoded JSON. This is converted into
455 a bunch of Python lists, dicts, ints, and strings. For example::
7364ea65 456
58ccea4e 457 x = twitter.statuses.home_timeline()
7364ea65 458
51e0b8f1
MV
459 # The first 'tweet' in the timeline
460 x[0]
7364ea65 461
51e0b8f1
MV
462 # The screen name of the user who wrote the first 'tweet'
463 x[0]['user']['screen_name']
4e9d6343 464
4e9d6343 465
68b3e2ee
MV
466 Getting raw XML data
467 --------------------
468
469 If you prefer to get your Twitter data in XML format, pass
470 format="xml" to the Twitter object when you instantiate it::
4e9d6343 471
51e0b8f1 472 twitter = Twitter(format="xml")
4e9d6343 473
51e0b8f1
MV
474 The output will not be parsed in any way. It will be a raw string
475 of XML.
68b3e2ee 476
7364ea65 477 """
45688301 478 def __init__(
fcf08b18 479 self, format="json",
480 domain="api.twitter.com", secure=True, auth=None,
491792a2 481 api_version=_DEFAULT, retry=False):
7364ea65 482 """
68b3e2ee
MV
483 Create a new twitter API connector.
484
485 Pass an `auth` parameter to use the credentials of a specific
486 user. Generally you'll want to pass an `OAuth`
69e1f98e
MV
487 instance::
488
489 twitter = Twitter(auth=OAuth(
490 token, token_secret, consumer_key, consumer_secret))
491
492
68b3e2ee 493 `domain` lets you change the domain you are connecting. By
fcf08b18 494 default it's `api.twitter.com`.
68b3e2ee
MV
495
496 If `secure` is False you will connect with HTTP instead of
497 HTTPS.
498
1cc9ab0b 499 `api_version` is used to set the base uri. By default it's
fcf08b18 500 '1.1'.
491792a2
BB
501
502 If `retry` is True, API rate limits will automatically be
503 handled by waiting until the next reset, as indicated by
73a242d6
BB
504 the X-Rate-Limit-Reset HTTP header. If retry is an integer,
505 it defines the number of retries attempted.
7364ea65 506 """
d20da7f3
MV
507 if not auth:
508 auth = NoAuth()
509
6c527e72 510 if (format not in ("json", "xml", "")):
fcf08b18 511 raise ValueError("Unknown data format '%s'" % (format))
68b3e2ee 512
652c5402 513 if api_version is _DEFAULT:
82a93c03 514 api_version = '1.1'
652c5402 515
1be4ce71 516 uriparts = ()
68b3e2ee 517 if api_version:
1be4ce71 518 uriparts += (str(api_version),)
68b3e2ee 519
9a148ed1 520 TwitterCall.__init__(
aec68959 521 self, auth=auth, format=format, domain=domain,
dd648a25 522 callable_cls=TwitterCall,
491792a2 523 secure=secure, uriparts=uriparts, retry=retry)
7e43e2ed 524
7364ea65 525
abddd419 526__all__ = ["Twitter", "TwitterError", "TwitterHTTPError", "TwitterResponse"]