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