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