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