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