]> jfr.im git - z_archive/twitter.git/blob - twitter/api.py
05d934c0c8a31dbece79d8102bb433930598b938
[z_archive/twitter.git] / twitter / api.py
1 # encoding: utf-8
2 from __future__ import unicode_literals, print_function
3
4 try:
5 import urllib.request as urllib_request
6 import urllib.error as urllib_error
7 except ImportError:
8 import urllib2 as urllib_request
9 import urllib2 as urllib_error
10
11 try:
12 from cStringIO import StringIO
13 except ImportError:
14 from io import BytesIO as StringIO
15
16 from .twitter_globals import POST_ACTIONS
17 from .auth import NoAuth
18
19 import re
20 import sys
21 import gzip
22 from time import sleep, time
23
24 try:
25 import http.client as http_client
26 except ImportError:
27 import httplib as http_client
28
29 try:
30 import json
31 except ImportError:
32 import simplejson as json
33
34
35 class _DEFAULT(object):
36 pass
37
38
39 class TwitterError(Exception):
40 """
41 Base Exception thrown by the Twitter object when there is a
42 general error interacting with the API.
43 """
44 pass
45
46
47 class TwitterHTTPError(TwitterError):
48 """
49 Exception thrown by the Twitter object when there is an
50 HTTP error interacting with twitter.com.
51 """
52 def __init__(self, e, uri, format, uriparts):
53 self.e = e
54 self.uri = uri
55 self.format = format
56 self.uriparts = uriparts
57 try:
58 data = self.e.fp.read()
59 except http_client.IncompleteRead as e:
60 # can't read the error text
61 # let's try some of it
62 data = e.partial
63 if self.e.headers.get('Content-Encoding') == 'gzip':
64 buf = StringIO(data)
65 f = gzip.GzipFile(fileobj=buf)
66 self.response_data = f.read()
67 else:
68 self.response_data = data
69 super(TwitterHTTPError, self).__init__(str(self))
70
71 def __str__(self):
72 fmt = ("." + self.format) if self.format else ""
73 return (
74 "Twitter sent status %i for URL: %s%s using parameters: "
75 "(%s)\ndetails: %s" % (
76 self.e.code, self.uri, fmt, self.uriparts,
77 self.response_data))
78
79
80 class 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
88 `response.headers.get('h')` to retrieve a header.
89 """
90
91 @property
92 def rate_limit_remaining(self):
93 """
94 Remaining requests in the current rate-limit.
95 """
96 return int(self.headers.get('X-Rate-Limit-Remaining', "0"))
97
98 @property
99 def rate_limit_limit(self):
100 """
101 The rate limit ceiling for that given request.
102 """
103 return int(self.headers.get('X-Rate-Limit-Limit', "0"))
104
105 @property
106 def rate_limit_reset(self):
107 """
108 Time in UTC epoch seconds when the rate limit will reset.
109 """
110 return int(self.headers.get('X-Rate-Limit-Reset', "0"))
111
112
113 class TwitterDictResponse(dict, TwitterResponse):
114 pass
115
116
117 class TwitterListResponse(list, TwitterResponse):
118 pass
119
120
121 def 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
132
133
134 POST_ACTIONS_RE = re.compile('(' + '|'.join(POST_ACTIONS) + r')(/\d+)?$')
135
136 def method_for_uri(uri):
137 if POST_ACTIONS_RE.search(uri):
138 return "POST"
139 return "GET"
140
141 class TwitterCall(object):
142
143 TWITTER_UNAVAILABLE_WAIT = 30 # delay after HTTP codes 502, 503 or 504
144
145 def __init__(
146 self, auth, format, domain, callable_cls, uri="",
147 uriparts=None, secure=True, timeout=None, gzip=False, retry=False):
148 self.auth = auth
149 self.format = format
150 self.domain = domain
151 self.callable_cls = callable_cls
152 self.uri = uri
153 self.uriparts = uriparts
154 self.secure = secure
155 self.timeout = timeout
156 self.gzip = gzip
157 self.retry = retry
158
159 def __getattr__(self, k):
160 try:
161 return object.__getattr__(self, k)
162 except AttributeError:
163 def extend_call(arg):
164 return self.callable_cls(
165 auth=self.auth, format=self.format, domain=self.domain,
166 callable_cls=self.callable_cls, timeout=self.timeout,
167 secure=self.secure, gzip=self.gzip, retry=self.retry,
168 uriparts=self.uriparts + (arg,))
169 if k == "_":
170 return extend_call
171 else:
172 return extend_call(k)
173
174 def __call__(self, **kwargs):
175 # Build the uri.
176 uriparts = []
177 for uripart in self.uriparts:
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
184 uriparts.append(part)
185 uri = '/'.join(uriparts)
186
187 method = kwargs.pop('_method', None) or method_for_uri(uri)
188
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.
191 id = kwargs.pop('id', None)
192 if id:
193 uri += "/%s" % (id)
194
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
200
201 # If an _timeout is specified in kwargs, use it
202 _timeout = kwargs.pop('_timeout', None)
203
204 secure_str = ''
205 if self.secure:
206 secure_str = 's'
207 dot = ""
208 if self.format:
209 dot = "."
210 uriBase = "http%s://%s/%s%s%s" % (
211 secure_str, self.domain, uri, dot, self.format)
212
213 # Check if argument tells whether img is already base64 encoded
214 b64_convert = not kwargs.pop("_base64", False)
215 if b64_convert:
216 import base64
217
218 # Catch media arguments to handle oauth query differently for multipart
219 media = None
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')
230
231 # Catch media arguments that are not accepted through multipart
232 # and are not yet base64 encoded
233 if b64_convert:
234 for arg in ['banner', 'image']:
235 if arg in kwargs:
236 kwargs[arg] = base64.b64encode(kwargs[arg])
237
238 headers = {'Accept-Encoding': 'gzip'} if self.gzip else dict()
239 body = None
240 arg_data = None
241 if self.auth:
242 headers.update(self.auth.generate_headers())
243 # Use urlencoded oauth args with no params when sending media
244 # via multipart and send it directly via uri even for post
245 arg_data = self.auth.encode_params(
246 uriBase, method, {} if media else kwargs)
247 if method == 'GET' or media:
248 uriBase += '?' + arg_data
249 else:
250 body = arg_data.encode('utf8')
251
252 # Handle query as multipart when sending media
253 if media:
254 BOUNDARY = b"###Python-Twitter###"
255 bod = []
256 bod.append(b'--' + BOUNDARY)
257 bod.append(
258 b'Content-Disposition: form-data; name="%s"' % mediafield.encode('utf-8'))
259 bod.append(b'Content-Transfer-Encoding: base64')
260 bod.append(b'')
261 bod.append(media)
262 for k, v in kwargs.items():
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'')
269 bod.append(v)
270 bod.append(b'--' + BOUNDARY + b'--')
271 body = b'\r\n'.join(bod)
272 headers['Content-Type'] = \
273 'multipart/form-data; boundary=%s' % BOUNDARY
274
275 if sys.version_info < (3, 0):
276 uriBase = uriBase.encode("utf-8")
277 for k in headers:
278 headers[k.encode('utf-8')] = headers.pop(k)
279
280 req = urllib_request.Request(uriBase, body, headers)
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)
285
286 def _handle_response(self, req, uri, arg_data, _timeout=None):
287 kwargs = {}
288 if _timeout:
289 kwargs['timeout'] = _timeout
290 try:
291 handle = urllib_request.urlopen(req, **kwargs)
292 if handle.headers['Content-Type'] in ['image/jpeg', 'image/png']:
293 return handle
294 try:
295 data = handle.read()
296 except http_client.IncompleteRead as e:
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':
301 # Handle gzip decompression
302 buf = StringIO(data)
303 f = gzip.GzipFile(fileobj=buf)
304 data = f.read()
305 if len(data) == 0:
306 return wrap_response({}, handle.headers)
307 elif "json" == self.format:
308 res = json.loads(data.decode('utf8'))
309 return wrap_response(res, handle.headers)
310 else:
311 return wrap_response(
312 data.decode('utf8'), handle.headers)
313 except urllib_error.HTTPError as e:
314 if (e.code == 304):
315 return []
316 else:
317 raise TwitterHTTPError(e, uri, self.format, arg_data)
318
319 def _handle_response_with_retry(self, req, uri, arg_data, _timeout=None):
320 retry = self.retry
321 while retry:
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):
331 delay = self.TWITTER_UNAVAILABLE_WAIT
332 print("Service unavailable; waiting for %ds..." % delay, file=sys.stderr)
333 else:
334 raise
335 if isinstance(retry, int):
336 if retry <= 0:
337 raise
338 retry -= 1
339 sleep(delay)
340
341
342 class Twitter(TwitterCall):
343 """
344 The minimalist yet fully featured Twitter API class.
345
346 Get RESTful data by accessing members of this class. The result
347 is decoded python objects (lists and dicts).
348
349 The Twitter API is documented at:
350
351 http://dev.twitter.com/doc
352
353
354 Examples::
355
356 from twitter import *
357
358 t = Twitter(
359 auth=OAuth(token, token_key, con_secret, con_secret_key)))
360
361 # Get your "home" timeline
362 t.statuses.home_timeline()
363
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)
372
373 # Update your status
374 t.statuses.update(
375 status="Using @sixohsix's sweet Python Twitter Tools.")
376
377 # Send a direct message
378 t.direct_messages.new(
379 user="billybob",
380 text="I think yer swell!")
381
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")
388
389 # An *optional* `_timeout` parameter can also be used for API
390 # calls which take much more time than normal or twitter stops
391 # responding for some reason:
392 t.users.lookup(
393 screen_name=','.join(A_LIST_OF_100_SCREEN_NAMES), \
394 _timeout=1)
395
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)
403 # by just reading your image from the web or a file in a string:
404 status = "PTT ★"
405 with open("example.png", "rb") as imagefile:
406 params = {"media[]": imagefile.read(), "status": status}
407 t.statuses.update_with_media(**params)
408
409 # Or by sending a base64 encoded image:
410 params = {"media[]": base64_image, "status": status, "_base64": True}
411 t.statuses.update_with_media(**params)
412
413
414 Searching Twitter::
415
416 # Search for the latest tweets about #pycon
417 t.search.tweets(q="#pycon")
418
419
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::
425
426 x = twitter.statuses.home_timeline()
427
428 # The first 'tweet' in the timeline
429 x[0]
430
431 # The screen name of the user who wrote the first 'tweet'
432 x[0]['user']['screen_name']
433
434
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::
440
441 twitter = Twitter(format="xml")
442
443 The output will not be parsed in any way. It will be a raw string
444 of XML.
445
446 """
447 def __init__(
448 self, format="json",
449 domain="api.twitter.com", secure=True, auth=None,
450 api_version=_DEFAULT, retry=False):
451 """
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`
456 instance::
457
458 twitter = Twitter(auth=OAuth(
459 token, token_secret, consumer_key, consumer_secret))
460
461
462 `domain` lets you change the domain you are connecting. By
463 default it's `api.twitter.com`.
464
465 If `secure` is False you will connect with HTTP instead of
466 HTTPS.
467
468 `api_version` is used to set the base uri. By default it's
469 '1.1'.
470
471 If `retry` is True, API rate limits will automatically be
472 handled by waiting until the next reset, as indicated by
473 the X-Rate-Limit-Reset HTTP header. If retry is an integer,
474 it defines the number of retries attempted.
475 """
476 if not auth:
477 auth = NoAuth()
478
479 if (format not in ("json", "xml", "")):
480 raise ValueError("Unknown data format '%s'" % (format))
481
482 if api_version is _DEFAULT:
483 api_version = '1.1'
484
485 uriparts = ()
486 if api_version:
487 uriparts += (str(api_version),)
488
489 TwitterCall.__init__(
490 self, auth=auth, format=format, domain=domain,
491 callable_cls=TwitterCall,
492 secure=secure, uriparts=uriparts, retry=retry)
493
494
495 __all__ = ["Twitter", "TwitterError", "TwitterHTTPError", "TwitterResponse"]