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