├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.md ├── birdy ├── __init__.py └── twitter.py ├── requirements.txt └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .komodotools 3 | .vscode 4 | birdy.komodoproject 5 | *.pyc 6 | /*.egg-info 7 | /dist -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2013-2017 Mitja Pagon, Inueni Ltd. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | include requirements.txt 4 | global-exclude __pycache__ 5 | global-exclude *.py[co] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # birdy 2 | 3 | `birdy` is a super awesome Twitter API client for Python in just a little under 400 LOC. 4 | 5 | ## TL;DR 6 | 7 | ### Features 8 | 9 | - [Future proof dynamic API with full REST and Streaming API coverage](#ok-im-sold-but-how-do-i-use-it-how-does-this-dynamic-api-construction-work) 10 | - [OAuth1 (user) and OAuth2 (app) authentication workflows](#great-what-about-authorization-how-do-i-get-my-access-tokens) 11 | - [Automatic JSON decoding](#automatic-json-decoding), 12 | [JSONObject](#jsonobject) 13 | - [ApiResponse](#apiresponse), [StreamResponse](#streamresponse) objects 14 | - [Informative exceptions](#informative-exceptions) 15 | - [Easily customizable through subclassing](#customize-and-extend-through-subclassing) 16 | - [Built on top of the excellent requests and requests-ouathlib libraries](#credits) 17 | 18 | 19 | ### Installation 20 | 21 | The easiest and recommended way to install `birdy` is from [PyPI](https://pypi.python.org/pypi/birdy) 22 | 23 | ``` 24 | pip install birdy 25 | ``` 26 | 27 | ### Usage 28 | 29 | Import client and initialize it: 30 | 31 | ```python 32 | from birdy.twitter import UserClient 33 | client = UserClient(CONSUMER_KEY, 34 | CONSUMER_SECRET, 35 | ACCESS_TOKEN, 36 | ACCESS_TOKEN_SECRET) 37 | ``` 38 | 39 | GET example (**GET users/show**): 40 | 41 | ```python 42 | response = client.api.users.show.get(screen_name='twitter') 43 | response.data 44 | ``` 45 | 46 | POST example (**POST statuses/update**): 47 | 48 | ```pyhton 49 | response = client.api.statuses.update.post(status='Hello @pybirdy!') 50 | ``` 51 | 52 | Dynamic URL example (**POST statuses/destroy/:id**): 53 | 54 | ```python 55 | response = client.api.statuses.destroy['240854986559455234'].post() 56 | ``` 57 | 58 | Streaming API example (**Public Stream POST statuses/filter**): 59 | 60 | ```python 61 | response = client.stream.statuses.filter.post(track='twitter') 62 | 63 | for data in response.stream(): 64 | print data 65 | ``` 66 | 67 | ## Supported Python version 68 | 69 | `birdy` works with both `python2` (2.7+) and `python3` (3.4+). 70 | 71 | ## Why another Python Twitter API client? Aren't there enough? 72 | 73 | The concept behind `birdy` is so simple and awesome that it just had to be done, and the result is a super light weight and easy to use API 74 | client, that covers the whole Twitter REST API in just a little under 400 lines of code. 75 | 76 | To achieve this, `birdy` relies on established, battle tested python libraries like `requests` and `requests-ouathlib` to do the heavy 77 | lifting, but more importantly it relies on Python's dynamic nature to automatically construct API calls (no individual wrapper functions for API resources needed). This allows `birdy` to cover all existing Twitter API resources and any future additions, without the need to update `birdy` itself. 78 | 79 | Includes full support for both **OAuth1** (user) and **OAuth2** (application) authentication workflows. 80 | 81 | Finally, `birdy` is simple and explicit by design, besides error handling and JSON decoding it doesn't process the returned data in any way, that is left for you to handle (who'd know better what to do with it). 82 | 83 | ## OK, I'm sold, but how do I use it? How does this dynamic API construction work? 84 | 85 | The easiest way to show you is by example. Lets say you want to query Twitter for @twitter user information. The Twitter API resource for this is **GET users/show** ([Twitter docs](https://dev.twitter.com/docs/api/1.1/get/users/show)). 86 | 87 | First you will need to import a client, here we import UserClient (OAuth1) and than initialize it. 88 | 89 | ```python 90 | from birdy.twitter import UserClient 91 | client = UserClient(CONSUMER_KEY, 92 | CONSUMER_SECRET, 93 | ACCESS_TOKEN, 94 | ACCESS_TOKEN_SECRET) 95 | ``` 96 | 97 | To query the **GET /users/show** API resource and pass in the parameter screen\_name='twitter' you do this. 98 | 99 | ```python 100 | resource = client.api.users.show 101 | response = resource.get(screen_name='twitter') 102 | ``` 103 | 104 | What happens here is very simple, `birdy` translates the `users.show` part after `client.api` into the appropriate API resource path 105 | (**'users/show'**). Then when you call get() on the resource, `birdy` constructs a full resource URL, appends any parameters passed to get() to it and makes a GET request to that URL and returns the result. 106 | 107 | Usually the above example would be shortened to just one line like this. 108 | 109 | ```python 110 | response = client.api.users.show.get(screen_name='twitter') 111 | ``` 112 | 113 | Making a post request is similar, if for example, you would like to post a status update, this is how to do it. The API resource is **POST 114 | statuses/update** ([Twitter docs](https://dev.twitter.com/docs/api/1.1/post/statuses/update)). 115 | 116 | ```python 117 | response = client.api.statuses.update.post(status='Hello @pybirdy!') 118 | ``` 119 | 120 | Like before the part after `client.api` gets converted to the correct path, only this time post() is called instead of get(), so `birdy` makes a POST request and pass parameters (and files) as part of the request body. 121 | 122 | For cases when dynamic values are part of the API resource URL, like when deleting a tweet at **POST statuses/destroy/:id** ([Twitter 123 | docs](https://dev.twitter.com/docs/api/1.1/post/statuses/destroy/:id)), `birdy` supports an alternative, dictionary lookup like, syntax. For example, deleting a tweet with id '240854986559455234' looks like this. 124 | 125 | ``` python 126 | response = client.api.statuses.destroy['240854986559455234'].post() 127 | ``` 128 | 129 | By now it should be clear what happens above, `birdy` builds the API resource path and than makes a POST request, the only difference is that part of the API path is provided like a dictionary key lookup. 130 | 131 | Actually any call can be written in this alternative syntax, use whichever you prefer. Both syntax forms can be freely combined as in the example above. Some more examples: 132 | 133 | ```python 134 | response = client.api['users/show'].get(screen_name='twitter') 135 | 136 | response = client.api['users']['show'].get(screen_name='twitter') 137 | 138 | response = client.api['statuses/destroy']['240854986559455234'].post() 139 | ``` 140 | 141 | ### Is Streaming API supported as well? 142 | 143 | Sure, since version 0.2, `birdy` comes with full support for Streaming API out of the box. Access to the Streaming API is provided by a special `StreamClient`. 144 | 145 | > `StreamClient` can't be used to obtain access tokens, but you can use `UserClient` to get them. 146 | 147 | To work with the Streaming API, first import the client and initialize it. 148 | 149 | ```python 150 | from birdy.twitter import StreamClient 151 | client = StreamClient(CONSUMER_KEY, 152 | CONSUMER_SECRET, 153 | ACCESS_TOKEN, 154 | ACCESS_TOKEN_SECRET) 155 | ``` 156 | 157 | To access resources on the **Public** stream, like **POST statuses/filter** ([Twitter docs](https://dev.twitter.com/docs/api/1.1/post/statuses/filter)) 158 | 159 | ```python 160 | resource = client.stream.statuses.filter.post(track='twitter') 161 | ``` 162 | 163 | For **User** stream resource **GET user** ([Twitter docs](https://dev.twitter.com/docs/api/1.1/get/user)) 164 | 165 | ```python 166 | resource = client.userstream.user.get() 167 | ``` 168 | 169 | And for **Site** stream resource **GET site** ([Twitter docs](https://dev.twitter.com/docs/api/1.1/get/site)) 170 | 171 | ```python 172 | resource = client.sitestream.site.get() 173 | ``` 174 | 175 | To access the data in the stream you iterate over `resource.stream()` like this 176 | 177 | ```python 178 | for data in resource.stream(): 179 | print data 180 | ``` 181 | 182 | ## Great, what about authorization? How do I get my access tokens? 183 | 184 | `birdy` supports both **OAuth1** and **OAuth2** authentication workflows by providing two different clients, a `UserClient` and `AppClient` 185 | respectively. While requests to API resources, like in above examples are the same in both clients, the workflow for obtaining access tokens is slightly different. 186 | 187 | > Before you get started, you will need to [register](https://dev.twitter.com/apps) your application with Twitter, to obtain your application's `CONSUMER_KEY` and `CONSUMER_SECRET`. 188 | 189 | ### OAuth1 workflow for user authenticated requests (UserClient) 190 | 191 | #### Step 1: Creating a client instance 192 | 193 | First you need to import the `UserClient` and create an instance with your apps `CONSUMER_KEY` and `CONSUMER_SECRET`. 194 | 195 | ```python 196 | from birdy.twitter import UserClient 197 | 198 | CONSUMER_KEY = 'YOUR_APPS_CONSUMER_KEY' 199 | CONSUMER_SECRET = 'YOUR_APPS_CONSUMER_SECRET' 200 | CALLBACK_URL = 'https://127.0.0.1:8000/callback' 201 | 202 | client = UserClient(CONSUMER_KEY, CONSUMER_SECRET) 203 | ``` 204 | 205 | #### Step 2: Get request token and authorization URL 206 | 207 | > Pass `callback_url` only if you have a Web app, Desktop and Mobile apps **do not** require it. 208 | 209 | Next you need to fetch request token from Twitter. If you are building a _Sign-in with Twitter_ type application it's done like this. 210 | 211 | ```python 212 | token = client.get_signin_token(CALLBACK_URL) 213 | ``` 214 | 215 | Otherwise like this. 216 | 217 | ```python 218 | token = client.get_authorize_token(CALLBACK_URL) 219 | ``` 220 | 221 | Save `token.oauth_token` and `token.oauth_token_secret` for later user, as this are not the final token and secret. 222 | 223 | ```python 224 | ACCESS_TOKEN = token.oauth_token 225 | ACCESS_TOKEN_SECRET = token.oauth_token_secret 226 | ``` 227 | 228 | Direct the user to Twitter authorization url obtained from `token.auth_url`. 229 | 230 | #### Step 3: OAuth verification 231 | 232 | > If you have a Desktop or Mobile app, `OAUTH_VERIFIER` is the PIN code, you can skip the part about extraction. 233 | 234 | After authorizing your application on Twitter, the user will be redirected back to the `callback_url` provided during client initialization in *Step 1*. 235 | 236 | You will need to extract the `OAUTH_VERIFIER` from the URL. Most web frameworks provide an easy way of doing this or you can parse the URL yourself using `urlparse` module (if that is your thing). 237 | 238 | Django and Flask examples: 239 | 240 | ```python 241 | #Django 242 | OAUTH_VERIFIER = request.GET['oauth_verifier'] 243 | 244 | #Flash 245 | OAUTH_VERIFIER = request.args.get('oauth_verifier') 246 | ``` 247 | 248 | Once you have the `OAUTH_VERIFIER` you can use it to obtain the final access token and secret. To do that you will need to create a new instance of `UserClient`, this time also passing in `ACCESS_TOKEN` and `ACCESS_TOKEN_SECRET` obtained in *Step 2* and then fetch the tokens. 249 | 250 | ```python 251 | client = UserClient(CONSUMER_KEY, CONSUMER_SECRET, 252 | ACCESS_TOKEN, ACCESS_TOKEN_SECRET) 253 | 254 | token = client.get_access_token(OAUTH_VERIFIER) 255 | ``` 256 | 257 | Now that you have the final access token and secret you can save `token.oauth_token` and `token.oauth_token_secret` to the database for later use, also you can use the client to start making API request immediately. For example, you can retrieve the users home timeline like this. 258 | 259 | ```python 260 | response = client.api.statuses.home_timeline.get() 261 | response.data 262 | ``` 263 | 264 | That's it you have successfully authorized the user, retrieved the tokens and can now make API calls on their behalf. 265 | 266 | ### OAuth2 workflow for app authenticated requests (AppClient) 267 | 268 | #### Step 1: Creating a client instance 269 | 270 | For OAuth2 you will be using the `AppClient`, so first you need to import it and create an instance with your apps `CONSUMER_KEY` and `CONSUMER_SECRET`. 271 | 272 | ```python 273 | from birdy.twitter import AppClient 274 | 275 | CONSUMER_KEY = 'YOUR_APPS_CONSUMER_KEY' 276 | CONSUMER_SECRET = 'YOUR_APPS_CONSUMER_SECRET' 277 | 278 | client = AppClient(CONSUMER_KEY, CONSUMER_SECRET) 279 | ``` 280 | 281 | #### Step 2: Getting the access token 282 | 283 | OAuth2 workflow is much simpler compared to OAuth1, to obtain the access token you simply do this. 284 | 285 | ```python 286 | access_token = client.get_access_token() 287 | ``` 288 | 289 | That's it, you can start using the client immediately to make API request on behalf of the app. It's recommended you save the `access_token` for later use. You initialize the client with a saved token like this. 290 | 291 | ```python 292 | client = AppClient(CONSUMER_KEY, CONSUMER_SECRET, SAVED_ACCESS_TOKEN) 293 | ``` 294 | 295 | Keep in mind that OAuth2 authenticated requests are **read-only** and not all API resources are available. Check [Twitter docs](https://dev.twitter.com/docs/api/1.1) for more information. 296 | 297 | ## Any other useful features I should know about? 298 | 299 | Of course, `birdy` comes with some handy features, to ease your development, right out of the box. Lets take a look at some of the 300 | goodies. 301 | 302 | ### Automatic JSON decoding 303 | 304 | JSON data returned by the REST and Streaming API is automatically decoded to native Python objects, no extra coding necessary, start using the data right away. 305 | 306 | ### JSONObject 307 | 308 | When decoding JSON data, `objects` are, instead of a regular Python dictionary, converted to a `JSONObject`, which is dictionary 309 | subclass with attribute style access in addition to regular dictionary lookup style, for convenience. The following code produces the same 310 | result 311 | 312 | ```python 313 | followers_count = response.data['followers_count'] 314 | 315 | followers_count = response.data.followers_count 316 | ``` 317 | 318 | ### ApiResponse 319 | 320 | Calls to REST API resources return a `ApiResponse`, which in addition to returned data, also gives you access to response headers (useful for checking rate limits) and resource URL. 321 | 322 | ```python 323 | response.data # decoded JSON data 324 | response.resource_url # resource URL 325 | response.headers # dictionary containing response HTTP headers 326 | ``` 327 | 328 | ### StreamResponse 329 | 330 | `StreamResponse` is returned when calling Streaming API resources and provides the **stream()** method which returns an iterator used to 331 | receive JSON decoded streaming data. Like `ApiResponse` it also gives you access to response headers and resource URL. 332 | 333 | ```python 334 | response.stream() # a generator method used to iterate over the stream 335 | 336 | for data in response.stream(): 337 | print data 338 | ``` 339 | 340 | ### Informative exceptions 341 | 342 | There are 4 types of exceptions in `birdy` all subclasses of base `BirdyException` (which is never directly raised). 343 | 344 | - `TwitterClientError` raised for connection and access token retrieval errors 345 | - `TwitterApiError` raised when Twitter returns an error 346 | - `TwitterAuthError` raised when authentication fails, 347 | `TwitterApiError` subclass 348 | - `TwitterRateLimitError` raised when rate limit for resource is reached, `TwitterApiError` subclass 349 | 350 | `TwitterApiError` and `TwitterClientError` instances (exepct for access token retrieval errors) provide a informative error description which includes the resource URL and request method used (very handy when tracking errors in logs), also available is the following: 351 | 352 | ```python 353 | exception.request_method # HTTP method used to make the request (GET or POST) 354 | exception.resource_url # URL of the API resource called 355 | exception.status_code # HTTP status code returned by Twitter 356 | exception.error_code # error code returned by Twitter 357 | exception.headers # dictionary containing response HTTP headers 358 | ``` 359 | 360 | ### Customize and extend through subclassing 361 | 362 | `birdy` was built with subclassing in mind, if you wish to change the way it works, all you have to do is subclass one of the clients and override some methods and you are good to go. 363 | 364 | > Subclassing a client and then using the subclass instance in your codeis actually **the recommended way** of using `birdy`. 365 | 366 | For example, if you don't wish to use `JSONObject` you have to override **get\_json\_object\_hook()** method. 367 | 368 | ```python 369 | from birdy.twitter import UserClient 370 | 371 | class MyClient(UserClient): 372 | @staticmethod 373 | def get_json_object_hook(data): 374 | return data 375 | 376 | client = MyClient(...) 377 | response = client.api.users.show.get(screen_name='twitter') 378 | ``` 379 | 380 | Or maybe, if you want global error handling for common errors, just override **handle\_response()** method. 381 | 382 | ```python 383 | class MyClient(UserClient): 384 | def handle_response(self, method, response): 385 | try: 386 | response = super(MyClient, self).handle_response(method, response) 387 | except TwitterApiError, e: 388 | ... 389 | # Your error handling code 390 | ... 391 | return response 392 | ``` 393 | 394 | Another use of subclassing is configuration of `requests.Session` instance ([docs](http://docs.python-requests.org/en/latest/api/#sessionapi)) used to make HTTP requests, to configure it, you override the 395 | **configure\_oauth\_session()** method. 396 | 397 | ```python 398 | class MyClient(UserClient): 399 | def configure_oauth_session(self, session): 400 | session = super(MyClient, self).configure_oauth_session(session) 401 | session.proxies = {'http': 'foo.bar:3128'} 402 | return session 403 | ``` 404 | 405 | ## Do you accept contributions and feature requests? 406 | 407 | **Yes**, both contributions (including feedback) and feature requests 408 | are welcome, the proper way in both cases is to first open an issue on 409 | [GitHub](https://github.com/inueni/birdy/issues) and we will take if 410 | from there. 411 | 412 | > Keep in mind that I work on this project on my free time, so I might not be able to respond right way. 413 | 414 | ## Credits 415 | 416 | `birdy` would not exists if not for the excellent [requests](http://www.python-requests.org) and [requests-oauthlib](https://requests-oauthlib.readthedocs.org/en/latest/) libraries and the wonderful [Python](http://www.python.org) programing language. 417 | 418 | ## Question, comments, ... 419 | 420 | If you need to contact me, you can find me on Twitter ([@sect2k](https://twitter.com/sect2k/)). 421 | -------------------------------------------------------------------------------- /birdy/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'Mitja Pagon ' 2 | __version__ = '0.3.2' -------------------------------------------------------------------------------- /birdy/twitter.py: -------------------------------------------------------------------------------- 1 | from requests.auth import HTTPBasicAuth 2 | from requests_oauthlib import OAuth1Session, OAuth2Session 3 | from oauthlib.oauth2 import BackendApplicationClient 4 | from oauthlib.common import to_unicode 5 | from . import __version__ 6 | 7 | import requests 8 | import json 9 | 10 | TWITTER_API_VERSION = '1.1' 11 | TWITTER_BASE_API_URL = 'https://%s.twitter.com' 12 | 13 | 14 | class BirdyException(Exception): 15 | def __init__(self, msg, resource_url=None, request_method=None, status_code=None, error_code=None, headers=None): 16 | self._msg = msg 17 | self.request_method = request_method 18 | self.resource_url = resource_url 19 | self.status_code= status_code 20 | self.error_code = error_code 21 | self.headers = headers 22 | 23 | def __str__(self): 24 | if self.request_method and self.resource_url: 25 | return '%s (%s %s)' % (self._msg, self.request_method, self.resource_url) 26 | return self._msg 27 | 28 | 29 | class TwitterClientError(BirdyException): 30 | pass 31 | 32 | 33 | class TwitterApiError(BirdyException): 34 | def __init__(self, msg, response=None, request_method=None, error_code=None): 35 | kwargs = { 36 | 'request_method': request_method, 37 | 'error_code': error_code, 38 | } 39 | if response is not None: 40 | kwargs.update( 41 | { 42 | 'status_code': response.status_code, 43 | 'resource_url': response.url, 44 | 'headers': response.headers, 45 | } 46 | ) 47 | 48 | super(TwitterApiError, self).__init__( 49 | msg, 50 | **kwargs 51 | ) 52 | 53 | 54 | class TwitterRateLimitError(TwitterApiError): 55 | pass 56 | 57 | 58 | class TwitterAuthError(TwitterApiError): 59 | pass 60 | 61 | 62 | class ApiComponent(object): 63 | def __init__(self, client, path=None): 64 | self._client = client 65 | self._path = path 66 | 67 | def __repr__(self): 68 | return '' % self._path 69 | 70 | def __getitem__(self, path): 71 | if not self._path is None: 72 | path = '%s/%s' % (self._path, path) 73 | return ApiComponent(self._client, path) 74 | 75 | def __getattr__(self, path): 76 | return self[path] 77 | 78 | def get(self, **params): 79 | if self._path is None: 80 | raise TypeError('Calling get() on an empty API path is not supported.') 81 | return self._client.request('GET', self._path, **params) 82 | 83 | def post(self, **params): 84 | if self._path is None: 85 | raise TypeError('Calling post() on an empty API path is not supported.') 86 | return self._client.request('POST', self._path, **params) 87 | 88 | def get_path(self): 89 | return self._path 90 | 91 | 92 | class BaseResponse(object): 93 | def __repr__(self): 94 | return '<%s: %s %s>' % (self.__class__.__name__, self.request_method, self.resource_url) 95 | 96 | 97 | class ApiResponse(BaseResponse): 98 | def __init__(self, response, request_method, json_data): 99 | self.resource_url = response.url 100 | self.headers = response.headers 101 | self.request_method = request_method 102 | self.data = json_data 103 | 104 | 105 | class StreamResponse(BaseResponse): 106 | def __init__(self, response, request_method, json_object_hook): 107 | self.resource_url = response.url 108 | self.headers = response.headers 109 | self.request_method = request_method 110 | self._stream_iter = response.iter_lines 111 | self._json_object_hook = json_object_hook 112 | 113 | def stream(self): 114 | for item in self._stream_iter(): 115 | if item: 116 | try: 117 | data = json.loads(item.decode('utf-8'), object_hook=self._json_object_hook) 118 | except: 119 | pass 120 | else: 121 | yield data 122 | 123 | 124 | class JSONObject(dict): 125 | def __getattr__(self, name): 126 | if name in self: 127 | return self[name] 128 | raise AttributeError('%s has no property named %s.' % (self.__class__.__name__, name)) 129 | 130 | 131 | class BaseTwitterClient(object): 132 | api_version = TWITTER_API_VERSION 133 | base_api_url = TWITTER_BASE_API_URL 134 | user_agent_string = 'Birdy Twitter Client v%s' % __version__ 135 | 136 | def __getattr__(self, path): 137 | return ApiComponent(self, path) 138 | 139 | def configure_oauth_session(self, session): 140 | session.headers = {'User-Agent': self.get_user_agent_string()} 141 | return session 142 | 143 | def get_user_agent_string(self): 144 | return self.user_agent_string 145 | 146 | def request(self, method, path, **params): 147 | method = method.upper() 148 | url = self.construct_resource_url(path) 149 | request_kwargs = {} 150 | params, files = self.sanitize_params(params) 151 | 152 | if method == 'GET': 153 | request_kwargs['params'] = params 154 | elif method == 'POST': 155 | request_kwargs['data'] = params 156 | request_kwargs['files'] = files 157 | 158 | try: 159 | response = self.make_api_call(method, url, **request_kwargs) 160 | except requests.RequestException as e: 161 | raise TwitterClientError( 162 | str(e), 163 | resource_url=url, 164 | request_method=method 165 | ) 166 | 167 | return self.handle_response(method, response) 168 | 169 | def construct_resource_url(self, path): 170 | paths = path.split('/') 171 | return '%s/%s/%s.json' % (self.base_api_url % paths[0], self.api_version, '/'.join(paths[1:])) 172 | 173 | def make_api_call(self, method, url, **request_kwargs): 174 | return self.session.request(method, url, **request_kwargs) 175 | 176 | def handle_response(self, method, response): 177 | try: 178 | data = response.json(object_hook=self.get_json_object_hook) 179 | except ValueError: 180 | data = None 181 | 182 | if response.status_code == 200: 183 | return ApiResponse(response, method, data) 184 | 185 | if response.status_code == 429: 186 | raise TwitterRateLimitError('Too many requests', response=response, request_method=method, error_code=429) 187 | 188 | if data is None: 189 | raise TwitterApiError('Unable to decode JSON response.', response=response, request_method=method) 190 | 191 | error_code, error_msg = self.get_twitter_error_details(data) 192 | kwargs = { 193 | 'response': response, 194 | 'request_method': method, 195 | 'error_code': error_code, 196 | } 197 | 198 | if response.status_code == 401 or 'Bad Authentication data' in error_msg: 199 | raise TwitterAuthError(error_msg, **kwargs) 200 | 201 | if response.status_code == 404: 202 | raise TwitterApiError('Invalid API resource.', **kwargs) 203 | 204 | raise TwitterApiError(error_msg, **kwargs) 205 | 206 | @staticmethod 207 | def sanitize_params(input_params): 208 | params, files = ({}, {}) 209 | 210 | for k, v in list(input_params.items()): 211 | if hasattr(v, 'read') and callable(v.read): 212 | files[k] = v 213 | elif isinstance(v, bool): 214 | if v: 215 | params[k] = 'true' 216 | else: 217 | params[k] = 'false' 218 | elif isinstance(v, list): 219 | params[k] = ','.join(v) 220 | else: 221 | params[k] = v 222 | return params, files 223 | 224 | @staticmethod 225 | def get_json_object_hook(data): 226 | return JSONObject(data) 227 | 228 | @staticmethod 229 | def get_twitter_error_details(data): 230 | code, msg = (None, 'An unknown error has occured processing your request.') 231 | errors = data.get('errors') if data else None 232 | 233 | if errors and isinstance(errors, list): 234 | code = errors[0]['code'] 235 | msg = errors[0]['message'] 236 | elif errors: 237 | code = errors['code'] 238 | msg = errors['message'] 239 | 240 | return (code, msg) 241 | 242 | 243 | class UserClient(BaseTwitterClient): 244 | def __init__(self, consumer_key, consumer_secret, access_token=None, access_token_secret=None): 245 | self.request_token_url = '%s/oauth/request_token' % self.base_api_url % 'api' 246 | self.access_token_url = '%s/oauth/access_token' % self.base_api_url % 'api' 247 | self.base_signin_url = '%s/oauth/authenticate' % self.base_api_url % 'api' 248 | self.base_authorize_url = '%s/oauth/authorize' % self.base_api_url % 'api' 249 | 250 | self.consumer_key = consumer_key 251 | self.consumer_secret = consumer_secret 252 | self.access_token = access_token 253 | self.access_token_secret = access_token_secret 254 | 255 | self.session = self.get_oauth_session() 256 | 257 | def get_oauth_session(self): 258 | return self.configure_oauth_session(OAuth1Session(client_key=self.consumer_key, 259 | client_secret=self.consumer_secret, 260 | resource_owner_key=self.access_token, 261 | resource_owner_secret=self.access_token_secret)) 262 | 263 | def get_signin_token(self, callback_url=None, auto_set_token=True, **kwargs): 264 | return self.get_request_token(self.base_signin_url, callback_url, auto_set_token, **kwargs) 265 | 266 | def get_authorize_token(self, callback_url=None, auto_set_token=True, **kwargs): 267 | return self.get_request_token(self.base_authorize_url, callback_url, auto_set_token, **kwargs) 268 | 269 | def get_request_token(self, base_auth_url=None, callback_url=None, auto_set_token=True, **kwargs): 270 | if callback_url: 271 | self.session._client.client.callback_uri = to_unicode(callback_url, 'utf-8') 272 | 273 | try: 274 | token = self.session.fetch_request_token(self.request_token_url) 275 | except requests.RequestException as e: 276 | raise TwitterClientError(str(e)) 277 | except ValueError as e: 278 | raise TwitterClientError('Response does not contain a token.') 279 | 280 | if base_auth_url: 281 | token['auth_url'] = self.session.authorization_url(base_auth_url, **kwargs) 282 | 283 | if auto_set_token: 284 | self.auto_set_token(token) 285 | 286 | return JSONObject(token) 287 | 288 | def get_access_token(self, oauth_verifier, auto_set_token=True): 289 | required = (self.access_token, self.access_token_secret) 290 | 291 | if not all(required): 292 | raise TwitterClientError('''%s must be initialized with access_token and access_token_secret 293 | to fetch authorized access token.''' % self.__class__.__name__) 294 | 295 | self.session._client.client.verifier = to_unicode(oauth_verifier, 'utf-8') 296 | 297 | try: 298 | token = self.session.fetch_access_token(self.access_token_url) 299 | except requests.RequestException as e: 300 | raise TwitterClientError(str(e)) 301 | except ValueError: 302 | raise TwitterClientError('Reponse does not contain a token.') 303 | 304 | if auto_set_token: 305 | self.auto_set_token(token) 306 | 307 | return JSONObject(token) 308 | 309 | def auto_set_token(self, token): 310 | self.access_token = token['oauth_token'] 311 | self.access_token_secret = token['oauth_token_secret'] 312 | self.session = self.get_oauth_session() 313 | 314 | 315 | class AppClient(BaseTwitterClient): 316 | def __init__(self, consumer_key, consumer_secret, access_token=None, token_type='bearer'): 317 | self.request_token_url = '%s/oauth2/token' % self.base_api_url % 'api' 318 | self.invalidate_token_url = '%s/oauth2/invalidate_token' % self.base_api_url % 'api' 319 | 320 | self.consumer_key = consumer_key 321 | self.consumer_secret = consumer_secret 322 | self.access_token = access_token 323 | self.token_type = token_type 324 | 325 | self.session = self.get_oauth_session() 326 | self.auth = HTTPBasicAuth(self.consumer_key, self.consumer_secret) 327 | 328 | def get_oauth_session(self): 329 | client = BackendApplicationClient(self.consumer_key) 330 | token = None 331 | 332 | if self.access_token: 333 | token = {'access_token': self.access_token, 'token_type': self.token_type } 334 | 335 | return self.configure_oauth_session(OAuth2Session(client=client, token=token)) 336 | 337 | def get_access_token(self, auto_set_token=True): 338 | data = {'grant_type': 'client_credentials'} 339 | 340 | try: 341 | response = self.session.post(self.request_token_url, auth=self.auth, data=data) 342 | data = json.loads(response.content.decode('utf-8')) 343 | access_token = data['access_token'] 344 | except requests.RequestException as e: 345 | raise TwitterClientError(str(e)) 346 | except (ValueError, KeyError): 347 | raise TwitterClientError('Response does not contain an access token.') 348 | 349 | if auto_set_token: 350 | self.access_token = access_token 351 | self.session = self.get_oauth_session() 352 | 353 | return access_token 354 | 355 | def invalidate_access_token(self): 356 | data = {'access_token': self.access_token} 357 | 358 | try: 359 | response = self.session.post(self.invalidate_token_url, auth=self.auth, data=data) 360 | except requests.RequestException as e: 361 | raise TwitterClientError(str(e)) 362 | else: 363 | if response.status_code == 200: 364 | access_token = self.access_token 365 | self.access_token = None 366 | self.session = self.get_oauth_session() 367 | return access_token 368 | 369 | raise TwitterClientError('Could not invalidate access token.') 370 | 371 | 372 | class StreamClient(BaseTwitterClient): 373 | def __init__(self, consumer_key, consumer_secret, access_token, access_token_secret): 374 | self.consumer_key = consumer_key 375 | self.consumer_secret = consumer_secret 376 | self.access_token = access_token 377 | self.access_token_secret = access_token_secret 378 | 379 | self.session = self.get_oauth_session() 380 | 381 | def get_oauth_session(self): 382 | return self.configure_oauth_session(OAuth1Session(client_key=self.consumer_key, 383 | client_secret=self.consumer_secret, 384 | resource_owner_key=self.access_token, 385 | resource_owner_secret=self.access_token_secret)) 386 | 387 | def make_api_call(self, method, url, **request_kwargs): 388 | return self.session.request(method, url, stream=True, **request_kwargs) 389 | 390 | def handle_response(self, method, response): 391 | 392 | if response.status_code == 200: 393 | return StreamResponse(response, method, self.get_json_object_hook) 394 | 395 | kwargs = { 396 | 'request_method': method, 397 | 'response': response, 398 | } 399 | 400 | if response.status_code == 401: 401 | raise TwitterAuthError('Unauthorized.', **kwargs) 402 | 403 | if response.status_code == 404: 404 | raise TwitterApiError('Invalid API resource.', **kwargs) 405 | 406 | if response.status_code == 420: 407 | raise TwitterRateLimitError(response.content, **kwargs) 408 | 409 | raise TwitterApiError(response.content, **kwargs) 410 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests>=1.2.3 2 | requests-oauthlib>=0.3.2 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | from __future__ import unicode_literals, print_function 4 | 5 | import os 6 | import sys 7 | from io import open 8 | from setuptools import setup, find_packages 9 | from birdy import __author__, __version__ 10 | 11 | try: 12 | from pypandoc import convert 13 | def read_md(f): 14 | return convert(f, 'rst') 15 | except ImportError: 16 | print("warning: pypandoc module not found, could not convert Markdown to RST") 17 | def read_md(f): 18 | return open(f, 'r', encoding='utf-8').read() 19 | 20 | 21 | if sys.argv[-1] == 'publish': 22 | try: 23 | import pypandoc 24 | except ImportError: 25 | print("pypandoc not installed.\nUse `pip install pypandoc`.") 26 | 27 | print("You probably want to also tag the version now:") 28 | print(" git tag -a %s -m 'version %s'" % (__version__, __version__)) 29 | print(" git push --tags") 30 | sys.exit() 31 | 32 | setup( 33 | name = 'birdy', 34 | version = __version__, 35 | install_requires = ( 36 | 'requests>=1.2.3', 37 | 'requests_oauthlib>=0.3.2', 38 | ), 39 | author = 'Mitja Pagon', 40 | author_email = 'mitja@inueni.com', 41 | license = 'MIT', 42 | url = 'https://github.com/inueni/birdy/', 43 | keywords = 'twitter api tweet birdy search', 44 | description = 'birdy is a super awesome Twitter API client for Python.', 45 | long_description = read_md('README.md'), 46 | include_package_data = True, 47 | packages = find_packages(), 48 | zip_safe=False, 49 | classifiers=[ 50 | 'Development Status :: 5 - Production/Stable', 51 | 'Environment :: Web Environment', 52 | 'Intended Audience :: Developers', 53 | 'Operating System :: OS Independent', 54 | 'License :: OSI Approved :: MIT License', 55 | 'Topic :: Software Development :: Libraries :: Python Modules', 56 | 'Topic :: Communications :: Chat', 57 | 'Topic :: Internet', 58 | 'Programming Language :: Python', 59 | "Programming Language :: Python :: 2", 60 | 'Programming Language :: Python :: 2.7', 61 | 'Programming Language :: Python :: 3', 62 | 'Programming Language :: Python :: 3.4', 63 | 'Programming Language :: Python :: 3.5', 64 | 'Programming Language :: Python :: 3.6', 65 | ] 66 | ) 67 | --------------------------------------------------------------------------------