]> jfr.im git - z_archive/twitter.git/blame - twitter/api.py
Update docstring to match current API
[z_archive/twitter.git] / twitter / api.py
CommitLineData
f0603331 1# encoding: utf-8
491792a2 2from __future__ import unicode_literals, print_function
f0603331 3
2b533cdc
MV
4from .util import PY_3_OR_HIGHER, actually_bytes
5
3930cc7b
MV
6try:
7 import urllib.request as urllib_request
8 import urllib.error as urllib_error
9except ImportError:
10 import urllib2 as urllib_request
11 import urllib2 as urllib_error
7364ea65 12
a403f1b3
JL
13try:
14 from cStringIO import StringIO
15except ImportError:
6204d0da 16 from io import BytesIO as StringIO
a403f1b3 17
14fc6b25
MG
18from .twitter_globals import POST_ACTIONS
19from .auth import NoAuth
4e9d6343 20
2ee65672 21import re
745061c1 22import sys
a403f1b3 23import gzip
491792a2 24from time import sleep, time
23dcf621 25
26try:
dea9a3e7 27 import http.client as http_client
23dcf621 28except ImportError:
dea9a3e7 29 import httplib as http_client
2ee65672 30
4b12a3a0 31try:
f1a8ed67 32 import json
4b12a3a0 33except ImportError:
f1a8ed67 34 import simplejson as json
35
fcf08b18 36
652c5402
MV
37class _DEFAULT(object):
38 pass
39
fcf08b18 40
5251ea48 41class TwitterError(Exception):
21e3bd23 42 """
64a8d213
B
43 Base Exception thrown by the Twitter object when there is a
44 general error interacting with the API.
21e3bd23 45 """
5251ea48 46 pass
47
fcf08b18 48
64a8d213
B
49class TwitterHTTPError(TwitterError):
50 """
51 Exception thrown by the Twitter object when there is an
52 HTTP error interacting with twitter.com.
53 """
1be4ce71 54 def __init__(self, e, uri, format, uriparts):
4b12a3a0
MV
55 self.e = e
56 self.uri = uri
57 self.format = format
58 self.uriparts = uriparts
7fe9aab6
HN
59 try:
60 data = self.e.fp.read()
dea9a3e7 61 except http_client.IncompleteRead as e:
7fe9aab6
HN
62 # can't read the error text
63 # let's try some of it
64 data = e.partial
e9fc8d86 65 if self.e.headers.get('Content-Encoding') == 'gzip':
7fe9aab6 66 buf = StringIO(data)
84d2da3d 67 f = gzip.GzipFile(fileobj=buf)
68 self.response_data = f.read()
69 else:
7fe9aab6 70 self.response_data = data
9f04d75d 71 super(TwitterHTTPError, self).__init__(str(self))
64a8d213
B
72
73 def __str__(self):
57b54437 74 fmt = ("." + self.format) if self.format else ""
68b3e2ee 75 return (
57b54437 76 "Twitter sent status %i for URL: %s%s using parameters: "
fcf08b18 77 "(%s)\ndetails: %s" % (
57b54437 78 self.e.code, self.uri, fmt, self.uriparts,
c7dd86d1 79 self.response_data))
64a8d213 80
fcf08b18 81
84d0a294
MV
82class 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
ba02331e 90 `response.headers.get('h')` to retrieve a header.
84d0a294 91 """
84d0a294 92
84d0a294
MV
93 @property
94 def rate_limit_remaining(self):
95 """
96 Remaining requests in the current rate-limit.
97 """
eeec9b00
IA
98 return int(self.headers.get('X-Rate-Limit-Remaining', "0"))
99
100 @property
101 def rate_limit_limit(self):
102 """
c53558ad 103 The rate limit ceiling for that given request.
eeec9b00
IA
104 """
105 return int(self.headers.get('X-Rate-Limit-Limit', "0"))
84d0a294
MV
106
107 @property
108 def rate_limit_reset(self):
109 """
110 Time in UTC epoch seconds when the rate limit will reset.
111 """
eeec9b00 112 return int(self.headers.get('X-Rate-Limit-Reset', "0"))
84d0a294
MV
113
114
ed23f46c
MV
115class TwitterDictResponse(dict, TwitterResponse):
116 pass
12bba6ac 117
abddd419 118
ed23f46c
MV
119class TwitterListResponse(list, TwitterResponse):
120 pass
fcf08b18 121
94803fc9 122
ed23f46c
MV
123def 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
abddd419 134
2b986d8d
BB
135
136POST_ACTIONS_RE = re.compile('(' + '|'.join(POST_ACTIONS) + r')(/\d+)?$')
137
4e5c4880 138def method_for_uri(uri):
2b986d8d
BB
139 if POST_ACTIONS_RE.search(uri):
140 return "POST"
141 return "GET"
0d6c0646 142
8fccbe95
MV
143
144def 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
7364ea65 168class TwitterCall(object):
dd648a25 169
6d508c66
BB
170 TWITTER_UNAVAILABLE_WAIT = 30 # delay after HTTP codes 502, 503 or 504
171
c8d451e8 172 def __init__(
fcf08b18 173 self, auth, format, domain, callable_cls, uri="",
491792a2 174 uriparts=None, secure=True, timeout=None, gzip=False, retry=False):
568331a9 175 self.auth = auth
a55e6a11 176 self.format = format
153dee29 177 self.domain = domain
dd648a25 178 self.callable_cls = callable_cls
7364ea65 179 self.uri = uri
b0dedfc0 180 self.uriparts = uriparts
9a148ed1 181 self.secure = secure
effd06bb 182 self.timeout = timeout
86318060 183 self.gzip = gzip
491792a2 184 self.retry = retry
fd2bc885 185
7364ea65 186 def __getattr__(self, k):
187 try:
188 return object.__getattr__(self, k)
189 except AttributeError:
e748eed8 190 def extend_call(arg):
191 return self.callable_cls(
192 auth=self.auth, format=self.format, domain=self.domain,
ff3ca197 193 callable_cls=self.callable_cls, timeout=self.timeout,
491792a2 194 secure=self.secure, gzip=self.gzip, retry=self.retry,
ff3ca197 195 uriparts=self.uriparts + (arg,))
e748eed8 196 if k == "_":
197 return extend_call
198 else:
199 return extend_call(k)
fd2bc885 200
7364ea65 201 def __call__(self, **kwargs):
8fccbe95
MV
202 kwargs = dict(kwargs)
203 uri = build_uri(self.uriparts, kwargs)
4e5c4880 204 method = kwargs.pop('_method', None) or method_for_uri(uri)
2b533cdc 205 domain = self.domain
612ececa 206
920528cd
MV
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
be5f32da 212
8fd7289d
IA
213 # If an _timeout is specified in kwargs, use it
214 _timeout = kwargs.pop('_timeout', None)
920528cd 215
568331a9
MH
216 secure_str = ''
217 if self.secure:
218 secure_str = 's'
6c527e72 219 dot = ""
1be4ce71 220 if self.format:
6c527e72 221 dot = "."
2b533cdc
MV
222 url_base = "http%s://%s/%s%s%s" % (
223 secure_str, domain, uri, dot, self.format)
568331a9 224
c1d973eb 225 # Check if argument tells whether img is already base64 encoded
759cdb65 226 b64_convert = not kwargs.pop("_base64", False)
c1d973eb
A
227 if b64_convert:
228 import base64
229
94fb8fab
R
230 # Catch media arguments to handle oauth query differently for multipart
231 media = None
759cdb65
MV
232 if 'media' in kwargs:
233 mediafield = 'media'
234 media = kwargs.pop('media')
2b533cdc 235 media_raw = True
759cdb65
MV
236 elif 'media[]' in kwargs:
237 mediafield = 'media[]'
238 media = kwargs.pop('media[]')
239 if b64_convert:
240 media = base64.b64encode(media)
2b533cdc 241 media_raw = False
94fb8fab 242
c1d973eb
A
243 # Catch media arguments that are not accepted through multipart
244 # and are not yet base64 encoded
245 if b64_convert:
0e197382 246 for arg in ['banner', 'image']:
c1d973eb
A
247 if arg in kwargs:
248 kwargs[arg] = base64.b64encode(kwargs[arg])
249
86318060 250 headers = {'Accept-Encoding': 'gzip'} if self.gzip else dict()
1ff50236 251 body = None
252 arg_data = None
1be4ce71 253 if self.auth:
568331a9 254 headers.update(self.auth.generate_headers())
94fb8fab
R
255 # Use urlencoded oauth args with no params when sending media
256 # via multipart and send it directly via uri even for post
1ff50236 257 arg_data = self.auth.encode_params(
2b533cdc 258 url_base, method, {} if media else kwargs)
94fb8fab 259 if method == 'GET' or media:
2b533cdc 260 url_base += '?' + arg_data
1be4ce71 261 else:
2b533cdc 262 body = arg_data.encode('utf-8')
c53558ad 263
94fb8fab
R
264 # Handle query as multipart when sending media
265 if media:
759cdb65 266 BOUNDARY = b"###Python-Twitter###"
94fb8fab 267 bod = []
759cdb65 268 bod.append(b'--' + BOUNDARY)
1ff50236 269 bod.append(
2b533cdc
MV
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')
759cdb65 276 bod.append(b'')
2b533cdc 277 bod.append(actually_bytes(media))
94fb8fab 278 for k, v in kwargs.items():
2b533cdc
MV
279 k = actually_bytes(k)
280 v = actually_bytes(v)
759cdb65 281 bod.append(b'--' + BOUNDARY)
2b533cdc
MV
282 bod.append(b'Content-Disposition: form-data; name="' + k + b'"')
283 bod.append(b'Content-Type: text/plain;charset=utf-8')
759cdb65 284 bod.append(b'')
94fb8fab 285 bod.append(v)
759cdb65 286 bod.append(b'--' + BOUNDARY + b'--')
2b533cdc
MV
287 bod.append(b'')
288 bod.append(b'')
759cdb65 289 body = b'\r\n'.join(bod)
2b533cdc 290 # print(body.decode('utf-8', errors='ignore'))
6551068a 291 headers['Content-Type'] = \
2b533cdc
MV
292 b'multipart/form-data; boundary=' + BOUNDARY
293
2b533cdc
MV
294 if not PY_3_OR_HIGHER:
295 url_base = url_base.encode("utf-8")
6551068a
R
296 for k in headers:
297 headers[actually_bytes(k)] = actually_bytes(headers.pop(k))
f5ca09ad 298
2b533cdc 299 req = urllib_request.Request(url_base, data=body, headers=headers)
491792a2
BB
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)
102acdb1 304
8fd7289d 305 def _handle_response(self, req, uri, arg_data, _timeout=None):
a5aab114 306 kwargs = {}
8fd7289d
IA
307 if _timeout:
308 kwargs['timeout'] = _timeout
7364ea65 309 try:
a5aab114 310 handle = urllib_request.urlopen(req, **kwargs)
918b8b48
GC
311 if handle.headers['Content-Type'] in ['image/jpeg', 'image/png']:
312 return handle
0fdfdc3d
DM
313 try:
314 data = handle.read()
dea9a3e7 315 except http_client.IncompleteRead as e:
0fdfdc3d
DM
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':
a403f1b3 320 # Handle gzip decompression
0fdfdc3d 321 buf = StringIO(data)
a403f1b3
JL
322 f = gzip.GzipFile(fileobj=buf)
323 data = f.read()
c1d973eb
A
324 if len(data) == 0:
325 return wrap_response({}, handle.headers)
326 elif "json" == self.format:
a403f1b3 327 res = json.loads(data.decode('utf8'))
abddd419 328 return wrap_response(res, handle.headers)
de072195 329 else:
456ec92b 330 return wrap_response(
a403f1b3 331 data.decode('utf8'), handle.headers)
3930cc7b 332 except urllib_error.HTTPError as e:
de072195 333 if (e.code == 304):
7364ea65 334 return []
de072195 335 else:
aec68959 336 raise TwitterHTTPError(e, uri, self.format, arg_data)
102acdb1 337
491792a2 338 def _handle_response_with_retry(self, req, uri, arg_data, _timeout=None):
73a242d6
BB
339 retry = self.retry
340 while retry:
491792a2
BB
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):
6d508c66 350 delay = self.TWITTER_UNAVAILABLE_WAIT
491792a2
BB
351 print("Service unavailable; waiting for %ds..." % delay, file=sys.stderr)
352 else:
353 raise
19e3d13d 354 if isinstance(retry, int) and not isinstance(retry, bool):
73a242d6
BB
355 if retry <= 0:
356 raise
357 retry -= 1
491792a2
BB
358 sleep(delay)
359
fcf08b18 360
7364ea65 361class Twitter(TwitterCall):
362 """
363 The minimalist yet fully featured Twitter API class.
4e9d6343 364
7364ea65 365 Get RESTful data by accessing members of this class. The result
366 is decoded python objects (lists and dicts).
367
51e0b8f1 368 The Twitter API is documented at:
153dee29 369
aec68959
MV
370 http://dev.twitter.com/doc
371
4e9d6343 372
7364ea65 373 Examples::
4e9d6343 374
d4f3123e
MV
375 from twitter import *
376
d09c0dd3 377 t = Twitter(
4d37729a 378 auth=OAuth(token, token_key, con_secret, con_secret_key))
4e9d6343 379
58ccea4e
MV
380 # Get your "home" timeline
381 t.statuses.home_timeline()
4e9d6343 382
d4f3123e
MV
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)
d09c0dd3
MV
391
392 # Update your status
393 t.statuses.update(
394 status="Using @sixohsix's sweet Python Twitter Tools.")
4e9d6343 395
51e0b8f1 396 # Send a direct message
d09c0dd3 397 t.direct_messages.new(
51e0b8f1
MV
398 user="billybob",
399 text="I think yer swell!")
7364ea65 400
d09c0dd3 401 # Get the members of tamtar's list "Things That Are Rad"
22379712 402 t.lists.members(owner_screen_name="tamtar", slug="things-that-are-rad")
be5f32da 403
8fd7289d 404 # An *optional* `_timeout` parameter can also be used for API
a5aab114 405 # calls which take much more time than normal or twitter stops
d4f3123e 406 # responding for some reason:
a5aab114
IA
407 t.users.lookup(
408 screen_name=','.join(A_LIST_OF_100_SCREEN_NAMES), \
8fd7289d 409 _timeout=1)
a5aab114 410
5a412b39
R
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)
d4f3123e 418 # by just reading your image from the web or a file in a string:
5702c7b9 419 status = "PTT ★"
5a412b39 420 with open("example.png", "rb") as imagefile:
880418b2 421 params = {"media[]": imagefile.read(), "status": status}
5a412b39 422 t.statuses.update_with_media(**params)
d4f3123e 423
5a412b39 424 # Or by sending a base64 encoded image:
880418b2 425 params = {"media[]": base64_image, "status": status, "_base64": True}
5a412b39
R
426 t.statuses.update_with_media(**params)
427
b0dedfc0 428
153dee29 429 Searching Twitter::
4e9d6343 430
58ccea4e
MV
431 # Search for the latest tweets about #pycon
432 t.search.tweets(q="#pycon")
153dee29 433
7364ea65 434
68b3e2ee
MV
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::
7364ea65 440
58ccea4e 441 x = twitter.statuses.home_timeline()
7364ea65 442
51e0b8f1
MV
443 # The first 'tweet' in the timeline
444 x[0]
7364ea65 445
51e0b8f1
MV
446 # The screen name of the user who wrote the first 'tweet'
447 x[0]['user']['screen_name']
4e9d6343 448
4e9d6343 449
68b3e2ee
MV
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::
4e9d6343 455
51e0b8f1 456 twitter = Twitter(format="xml")
4e9d6343 457
51e0b8f1
MV
458 The output will not be parsed in any way. It will be a raw string
459 of XML.
68b3e2ee 460
7364ea65 461 """
45688301 462 def __init__(
fcf08b18 463 self, format="json",
464 domain="api.twitter.com", secure=True, auth=None,
491792a2 465 api_version=_DEFAULT, retry=False):
7364ea65 466 """
68b3e2ee
MV
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`
69e1f98e
MV
471 instance::
472
473 twitter = Twitter(auth=OAuth(
474 token, token_secret, consumer_key, consumer_secret))
475
476
68b3e2ee 477 `domain` lets you change the domain you are connecting. By
fcf08b18 478 default it's `api.twitter.com`.
68b3e2ee
MV
479
480 If `secure` is False you will connect with HTTP instead of
481 HTTPS.
482
1cc9ab0b 483 `api_version` is used to set the base uri. By default it's
fcf08b18 484 '1.1'.
491792a2
BB
485
486 If `retry` is True, API rate limits will automatically be
487 handled by waiting until the next reset, as indicated by
73a242d6
BB
488 the X-Rate-Limit-Reset HTTP header. If retry is an integer,
489 it defines the number of retries attempted.
7364ea65 490 """
d20da7f3
MV
491 if not auth:
492 auth = NoAuth()
493
6c527e72 494 if (format not in ("json", "xml", "")):
fcf08b18 495 raise ValueError("Unknown data format '%s'" % (format))
68b3e2ee 496
652c5402 497 if api_version is _DEFAULT:
82a93c03 498 api_version = '1.1'
652c5402 499
1be4ce71 500 uriparts = ()
68b3e2ee 501 if api_version:
1be4ce71 502 uriparts += (str(api_version),)
68b3e2ee 503
9a148ed1 504 TwitterCall.__init__(
aec68959 505 self, auth=auth, format=format, domain=domain,
dd648a25 506 callable_cls=TwitterCall,
491792a2 507 secure=secure, uriparts=uriparts, retry=retry)
7e43e2ed 508
7364ea65 509
abddd419 510__all__ = ["Twitter", "TwitterError", "TwitterHTTPError", "TwitterResponse"]