├── README.md ├── __init__.py ├── api_models.py ├── api_models_test.py ├── oauth.py ├── secrets.py.template └── test_oauth_client.py /README.md: -------------------------------------------------------------------------------- 1 | Khan Academy API (Python wrapper) 2 | ========= 3 | 4 | This is a Python wrapper for the Khan Academy API. 5 | 6 | Documentation 7 | Khan Academy API: https://github.com/Khan/khan-api/wiki/Khan-Academy-API 8 | 9 | To use: 10 | 11 | In order to support multiple authentication sessions to the Khan Academy API, and different language settings, every call to the API is done through a Khan() session. 12 | 13 | ```python 14 | from api_models import * 15 | ``` 16 | By default lang is set to "en", here we are setting it to Spanish. 17 | ```python 18 | khan = Khan(lang="es") 19 | ``` 20 | Get entire Khan Academy topic tree 21 | ```python 22 | topic_tree = khan.get_topic_tree() 23 | ``` 24 | Get information for a user - by default it will be whatever user you log in as, but if you are a coach for other users, can retrieve their information also 25 | If not already authenticated, this will create an OAuth authentication session which will need to be verified via the browser. 26 | ```python 27 | current_user = khan.get_user() 28 | ``` 29 | 30 | Khan session object methods available for most documented items in the API. 31 | 32 | ```python 33 | khan.get_badge_category() 34 | khan.get_badges() 35 | khan.get_exercise("") 36 | khan.get_exercises() 37 | khan.get_topic_exercises("") 38 | khan.get_topic_videos("") 39 | khan.get_topic_tree() 40 | khan.get_user("") 41 | khan.get_video("") 42 | khan.get_playlists() 43 | khan.get_playlist_exercises("") 44 | khan.get_playlist_videos("") 45 | ``` 46 | 47 | No authentication is required for anything but user data. In order to authenticate to retrieve user data, the secrets.py.template needs to be copied to secrets.py and a CONSUMER_KEY and CONSUMER_SECRET entered. 48 | 49 | In addition to documented API endpoints, this wrapper also exposes the following functions. 50 | 51 | ```python 52 | khan.get_videos() 53 | khan.get_assessment_item("") 54 | khan.get_tags() 55 | ``` 56 | 57 | 58 | You can register your app with the Khan Academy API here to get these two items: 59 | https://www.khanacademy.org/api-apps/register -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/learningequality/khan-api-python/540a1da8e72ffc9f35cb6821786fc33ace088f7a/__init__.py -------------------------------------------------------------------------------- /api_models.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import urllib 3 | import json 4 | import cgi 5 | import os 6 | import SocketServer 7 | import SimpleHTTPServer 8 | import sys 9 | import copy 10 | from functools import partial 11 | 12 | try: 13 | from secrets import CONSUMER_KEY, CONSUMER_SECRET 14 | except ImportError: 15 | CONSUMER_KEY = None 16 | CONSUMER_SECRET = None 17 | from test_oauth_client import TestOAuthClient 18 | from oauth import OAuthToken 19 | 20 | 21 | class APIError(Exception): 22 | 23 | """ 24 | Custom Exception Class for returning meaningful errors which are caused by changes 25 | in the Khan Academy API. 26 | """ 27 | 28 | def __init__(self, msg, obj=None): 29 | self.msg = msg 30 | self.obj = obj 31 | 32 | def __str__(self): 33 | inspection = "" 34 | if self.obj: 35 | for id in id_to_kind_map: 36 | if id(self.obj): 37 | inspection = "This occurred in an object of kind %s, called %s." % ( 38 | id_to_kind_map[id], id(self.obj)) 39 | if not inspection: 40 | inspection = "Object could not be inspected. Summary of object keys here: %s" % str( 41 | self.obj.keys()) 42 | return "Khan API Error: %s %s" % (self.msg, inspection) 43 | 44 | 45 | def create_callback_server(session): 46 | """ 47 | Adapted from https://github.com/Khan/khan-api/blob/master/examples/test_client/test.py 48 | Simple server to handle callbacks from OAuth request to browser. 49 | """ 50 | 51 | class CallbackHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): 52 | 53 | def do_GET(self): 54 | 55 | params = cgi.parse_qs(self.path.split( 56 | '?', 1)[1], keep_blank_values=False) 57 | session.REQUEST_TOKEN = OAuthToken(params['oauth_token'][ 58 | 0], params['oauth_token_secret'][0]) 59 | session.REQUEST_TOKEN.set_verifier(params['oauth_verifier'][0]) 60 | 61 | self.send_response(200) 62 | self.send_header('Content-Type', 'text/plain') 63 | self.end_headers() 64 | self.wfile.write( 65 | 'OAuth request token fetched; you can close this window.') 66 | 67 | def log_request(self, code='-', size='-'): 68 | pass 69 | 70 | server = SocketServer.TCPServer(('127.0.0.1', 0), CallbackHandler) 71 | return server 72 | 73 | 74 | class AttrDict(dict): 75 | 76 | """ 77 | Base class to give dictionary values from JSON objects are object properties. 78 | Recursively turn all dictionary sub-objects, and lists of dictionaries 79 | into AttrDicts also. 80 | """ 81 | 82 | def __init__(self, *args, **kwargs): 83 | super(AttrDict, self).__init__(*args, **kwargs) 84 | 85 | def __getattr__(self, name): 86 | value = self[name] 87 | if isinstance(value, dict): 88 | value = AttrDict(value) 89 | if isinstance(value, list): 90 | for i in range(len(value)): 91 | if isinstance(value[i], dict): 92 | value[i] = AttrDict(value[i]) 93 | return value 94 | 95 | def __setattr__(self, name, value): 96 | self[name] = value 97 | 98 | 99 | class APIModel(AttrDict): 100 | 101 | # _related_field_types = None # this is a dummy; do not use directly 102 | 103 | # _lazy_related_field_types = None # this is a dummy. 104 | 105 | # _API_attributes = None # this is also a dummy. 106 | 107 | def __getattr__(self, name): 108 | """ 109 | Check to see if the attribute already exists in the object. 110 | If so, return that attribute according to super. 111 | If not, and the attribute is in API_attributes for this class, 112 | then make the appropriate API call to fetch the data, and set it 113 | into the object, so that repeated queries will not requery the API. 114 | """ 115 | if name in self: 116 | if name.startswith("_"): 117 | return super(APIModel, self).__getattr__(name) 118 | if name in self._lazy_related_field_types or name in self._related_field_types: 119 | self._session.convert_items(name, self, loaded=(name in self._related_field_types)) 120 | return self[name] 121 | else: 122 | return super(APIModel, self).__getattr__(name) 123 | if name in self._API_attributes: 124 | self[name] = api_call("v1", self.API_url(name), self._session) 125 | self._session.convert_items(name, self) 126 | return self[name] 127 | if not self._loaded and name not in self: 128 | self.fetch() 129 | if name in self._related_field_types: 130 | self._session.convert_items(name, self) 131 | return self[name] 132 | else: 133 | return super(APIModel, self).__getattr__(name) 134 | 135 | def __init__(self, *args, **kwargs): 136 | 137 | session = kwargs.get('session') 138 | loaded = kwargs.get('loaded', True) 139 | kwargs.pop('session', None) 140 | kwargs.pop('loaded', None) 141 | super(APIModel, self).__init__(*args, **kwargs) 142 | self._session = session 143 | self._loaded = loaded 144 | self._related_field_types = {} 145 | self._lazy_related_field_types = {} 146 | self._API_attributes = {} 147 | self._api_version = "v1" 148 | 149 | def API_url(self, name): 150 | """ 151 | Generate the url from which to make API calls. 152 | """ 153 | id = "/" + kind_to_id_map.get(self.kind)( 154 | self) if kind_to_id_map.get(self.kind) else "" 155 | get_param = "?" + get_key_to_get_param_map.get(kind_to_get_key_map.get( 156 | self.kind)) + "=" + self.get(kind_to_get_key_map.get(self.kind)) if kind_to_get_key_map.get(self.kind) else "" 157 | if self._session.lang: 158 | get_param = get_param + "&lang=" if get_param else "?lang=" 159 | get_param += self._session.lang 160 | return self.base_url + id + self._API_attributes[name] + get_param 161 | 162 | def fetch(self): 163 | self.update(api_call( 164 | self._api_version, self.base_url + "/" + self[kind_to_id_map.get(type(self).__name__, "id")], self._session)) 165 | self._loaded = True 166 | 167 | def toJSON(self): 168 | output = {} 169 | for key in self._related_field_types.keys() + self._lazy_related_field_types.keys(): 170 | if self.get(key, None): 171 | if isinstance(self[key], APIModel): 172 | output[key] = self[key].toJSON() 173 | elif isinstance(self[key], dict): 174 | output[key] = json.dumps(self[key]) 175 | elif isinstance(self[key], list): 176 | output[key] = [] 177 | for i, item in enumerate(self[key]): 178 | if isinstance(self[key][i], APIModel): 179 | output[key].append(self[key][i].toJSON()) 180 | elif isinstance(self[key][i], dict): 181 | output[key].append(json.dumps(self[key][i])) 182 | for key in self: 183 | if key not in self._related_field_types.keys() + self._lazy_related_field_types.keys(): 184 | if not (key.startswith("_") or hasattr(self[key], '__call__')): 185 | output[key] = self[key] 186 | return json.dumps(output) 187 | 188 | def api_call(target_version, target_api_url, session, debug=False, authenticate=True): 189 | """ 190 | Generic API call function, that will try to use an authenticated request if available, 191 | otherwise will fall back to non-authenticated request. 192 | """ 193 | # TODO : Use requests for both kinds of authentication. 194 | # usage : api_call("v1", "/badges") 195 | resource_url = "/api/" + target_version + target_api_url 196 | try: 197 | if authenticate and session.REQUEST_TOKEN and session.ACCESS_TOKEN: 198 | client = TestOAuthClient( 199 | session.SERVER_URL, CONSUMER_KEY, CONSUMER_SECRET) 200 | response = client.access_resource( 201 | resource_url, session.ACCESS_TOKEN) 202 | else: 203 | response = requests.get(session.SERVER_URL + resource_url).content 204 | json_object = json.loads(response) 205 | except Exception as e: 206 | print e, "for target: %(target)s " % {"target": target_api_url} 207 | return {} 208 | if(debug): 209 | print json_object 210 | return json_object 211 | 212 | 213 | def n_deep(obj, names): 214 | """ 215 | A function to descend len(names) levels in an object and retrieve the attribute there. 216 | """ 217 | for name in names: 218 | try: 219 | obj = getattr(obj, name) 220 | except KeyError: 221 | raise APIError( 222 | "This object is missing the %s attribute." % name, obj) 223 | return obj 224 | 225 | 226 | class Khan(): 227 | 228 | SERVER_URL = "http://www.khanacademy.org" 229 | 230 | # Set authorization objects to prevent errors when checking for Auth. 231 | 232 | def __init__(self, lang=None): 233 | self.lang = lang 234 | self.REQUEST_TOKEN = None 235 | self.ACCESS_TOKEN = None 236 | 237 | def require_authentication(self): 238 | """ 239 | Decorator to require authentication for particular request events. 240 | """ 241 | if not (self.REQUEST_TOKEN and self.ACCESS_TOKEN): 242 | print "This data requires authentication." 243 | self.authenticate() 244 | return (self.REQUEST_TOKEN and self.ACCESS_TOKEN) 245 | 246 | def authenticate(self): 247 | """ 248 | Adapted from https://github.com/Khan/khan-api/blob/master/examples/test_client/test.py 249 | First pass at browser based OAuth authentication. 250 | """ 251 | # TODO: Allow PIN access for non-browser enabled devices. 252 | 253 | if CONSUMER_KEY and CONSUMER_SECRET: 254 | 255 | server = create_callback_server(self) 256 | 257 | client = TestOAuthClient( 258 | self.SERVER_URL, CONSUMER_KEY, CONSUMER_SECRET) 259 | 260 | client.start_fetch_request_token( 261 | 'http://127.0.0.1:%d/' % server.server_address[1]) 262 | 263 | server.handle_request() 264 | 265 | server.server_close() 266 | 267 | self.ACCESS_TOKEN = client.fetch_access_token(self.REQUEST_TOKEN) 268 | else: 269 | print "Consumer key and secret not set in secrets.py - authenticated access to API unavailable." 270 | 271 | def class_by_kind(self, node, session=None, loaded=True): 272 | """ 273 | Function to turn a dictionary into a Python object of the appropriate kind, 274 | based on the "kind" attribute found in the dictionary. 275 | """ 276 | # TODO: Fail better or prevent failure when "kind" is missing. 277 | try: 278 | return kind_to_class_map[node["kind"]](node, session=self, loaded=loaded) 279 | except KeyError: 280 | raise APIError( 281 | "This kind of object should have a 'kind' attribute.", node) 282 | 283 | def convert_list_to_classes(self, nodelist, session=None, class_converter=None, loaded=True): 284 | """ 285 | Convert each element of the list (in-place) into an instance of a subclass of APIModel. 286 | You can pass a particular class to `class_converter` if you want to, or it will auto-select by kind. 287 | """ 288 | if not class_converter: 289 | class_converter = self.class_by_kind 290 | for i in range(len(nodelist)): 291 | nodelist[i] = class_converter(nodelist[i], session=self, loaded=loaded) 292 | 293 | return nodelist # just for good measure; it's already been changed 294 | 295 | def class_by_name(self, node, name, session=None, loaded=True): 296 | """ 297 | Function to turn a dictionary into a Python object of the kind given by name. 298 | """ 299 | if isinstance(node, str) or isinstance(node, unicode): 300 | # Assume just an id has been supplied - otherwise there's not much we can do. 301 | node = {"id": node} 302 | if isinstance(node, dict): 303 | return kind_to_class_map[name](node, session=self, loaded=loaded) 304 | else: 305 | return node 306 | 307 | def convert_items(self, name, obj, loaded=True): 308 | """ 309 | Convert attributes of an object to related object types. 310 | If in a list call to convert each element of the list. 311 | """ 312 | class_converter = obj._related_field_types.get(name, None) or obj._lazy_related_field_types.get(name, None) 313 | # convert dicts to the related type 314 | if isinstance(obj[name], dict): 315 | obj[name] = class_converter(obj[name], session=self, loaded=loaded) 316 | # convert every item in related list to correct type 317 | elif isinstance(obj[name], list): 318 | self.convert_list_to_classes(obj[ 319 | name], class_converter=class_converter, loaded=loaded) 320 | 321 | def params(self, **kwargs): 322 | if self.lang: 323 | kwargs["lang"] = self.lang 324 | paramstring = urllib.urlencode(kwargs) 325 | return "?" + paramstring if paramstring else "" 326 | 327 | def get_items(self, kind, child_key="child_data"): 328 | """ 329 | As no list API endpoint is provided for several content types by Khan Academy, this function fetches the topic tree, 330 | and recurses all the nodes in order to find all the content of a certain type in the topic tree. 331 | """ 332 | topic_tree = self.get_topic_tree() 333 | 334 | item_nodes = {} 335 | 336 | def recurse_nodes(node): 337 | 338 | # Do the recursion 339 | for child in node.get("children", []): 340 | recurse_nodes(child) 341 | 342 | for child in node.get(child_key, []): 343 | # Add the item to the item nodes 344 | child_kind = child["kind"] 345 | if child["id"] not in item_nodes and child_kind==kind: 346 | item_nodes[child["id"]] = child 347 | recurse_nodes(topic_tree) 348 | 349 | return self.convert_list_to_classes(item_nodes.values(), loaded=False) 350 | 351 | def get_exercises(self): 352 | """ 353 | Return list of all exercises in the Khan API 354 | """ 355 | return self.convert_list_to_classes(api_call("v1", Exercise.base_url + self.params(), self)) 356 | 357 | def get_exercise(self, exercise_id): 358 | """ 359 | Return particular exercise, by "exercise_id" 360 | """ 361 | return Exercise(api_call("v1", Exercise.base_url + "/" + exercise_id + self.params(), self), session=self) 362 | 363 | def get_badges(self): 364 | """ 365 | Return list of all badges in the Khan API 366 | """ 367 | return self.convert_list_to_classes(api_call("v1", Badge.base_url + self.params(), self)) 368 | 369 | def get_badge_category(self, category_id=None): 370 | """ 371 | Return list of all badge categories in the Khan API, or a particular category. 372 | """ 373 | if category_id is not None: 374 | return BadgeCategory(api_call("v1", BadgeCategory.base_url + "/categories/" + str(category_id) + self.params(), self)[0], session=self) 375 | else: 376 | return self.convert_list_to_classes(api_call("v1", BadgeCategory.base_url + "/categories" + self.params(), self)) 377 | 378 | def get_user(self, user_id=""): 379 | """ 380 | Download user data for a particular user. 381 | If no user specified, download logged in user's data. 382 | """ 383 | if self.require_authentication(): 384 | return User(api_call("v1", User.base_url + "?userId=" + user_id + self.params(), self), session=self) 385 | 386 | def get_topic_tree(self): 387 | """ 388 | Retrieve complete node tree starting at the specified root_slug and descending. 389 | """ 390 | return Topic(api_call("v1", "/topictree" + self.params(), self), session=self) 391 | 392 | def get_topic(self, topic_slug): 393 | """ 394 | Retrieve complete topic at the specified topic_slug and descending. 395 | """ 396 | return Topic(api_call("v1", Topic.base_url + "/" + topic_slug + self.params(), self), session=self) 397 | 398 | def get_topic_exercises(self, topic_slug): 399 | """ 400 | This will return a list of exercises in the highest level of a topic. 401 | Not lazy loading from get_tree, as any load of the topic data includes these. 402 | """ 403 | return self.convert_list_to_classes(api_call("v1", Topic.base_url + "/" + topic_slug + "/exercises" + self.params(), self)) 404 | 405 | def get_topic_videos(self, topic_slug): 406 | """ 407 | This will return a list of videos in the highest level of a topic. 408 | Not lazy loading from get_tree, as any load of the topic data includes these. 409 | """ 410 | return self.convert_list_to_classes(api_call("v1", Topic.base_url + "/" + topic_slug + "/videos" + self.params(), self)) 411 | 412 | def get_video(self, video_id): 413 | """ 414 | Return particular video, by "readable_id" or "youtube_id" (deprecated) 415 | """ 416 | return Video(api_call("v1", Video.base_url + "/" + video_id + self.params(), self), session=self) 417 | 418 | def get_videos(self): 419 | """ 420 | Return list of all videos. 421 | """ 422 | return self.get_items("Video", child_key="children") 423 | 424 | def get_playlists(self): 425 | """ 426 | Return list of all playlists in the Khan API 427 | """ 428 | return self.convert_list_to_classes(api_call("v1", Playlist.base_url + self.params(), self)) 429 | 430 | def get_playlist_exercises(self, topic_slug): 431 | """ 432 | This will return a list of exercises in a playlist. 433 | """ 434 | return self.convert_list_to_classes(api_call("v1", Playlist.base_url + "/" + topic_slug + "/exercises" + self.params(), self)) 435 | 436 | def get_playlist_videos(self, topic_slug): 437 | """ 438 | This will return a list of videos in the highest level of a playlist. 439 | """ 440 | return self.convert_list_to_classes(api_call("v1", Playlist.base_url + "/" + topic_slug + "/videos" + self.params(), self)) 441 | 442 | def get_assessment_item(self, assessment_id): 443 | """ 444 | Return particular assessment item, by "assessment_id" 445 | """ 446 | return AssessmentItem(api_call("v1", AssessmentItem.base_url + "/" + assessment_id + self.params(), self), session=self) 447 | 448 | def get_tags(self): 449 | """ 450 | Return list of all assessment item tags in the Khan API 451 | """ 452 | return self.convert_list_to_classes(api_call("v1", Tag.base_url + self.params(), self), class_converter=Tag) 453 | 454 | def get_scratchpad(self, scratchpad_id): 455 | """ 456 | Return particular Scratchpad, by "scratchpad_id" 457 | """ 458 | return Scratchpad(api_call("internal", Scratchpad.base_url + self.params(scratchpad_id=scratchpad_id, projection='{"scratchpad":true}'), self), session=self) 459 | 460 | def get_scratchpads(self): 461 | """ 462 | Return all Scratchpads, lazily loaded 463 | """ 464 | return self.get_items("Scratchpad") 465 | 466 | class Exercise(APIModel): 467 | 468 | base_url = "/exercises" 469 | 470 | _API_attributes = { 471 | "related_videos": "/videos", 472 | "followup_exercises": "/followup_exercises" 473 | } 474 | 475 | def __init__(self, *args, **kwargs): 476 | 477 | super(Exercise, self).__init__(*args, **kwargs) 478 | self._related_field_types = { 479 | "related_videos": partial(self._session.class_by_name, name="Video"), 480 | "followup_exercises": partial(self._session.class_by_name, name="Exercise"), 481 | "problem_types": partial(self._session.class_by_name, name="ProblemType"), 482 | } 483 | self._lazy_related_field_types = { 484 | "all_assessment_items": partial(self._session.class_by_name, name="AssessmentItem"), 485 | } 486 | 487 | 488 | class ProblemType(APIModel): 489 | def __init__(self, *args, **kwargs): 490 | super(ProblemType, self).__init__(*args, **kwargs) 491 | self._lazy_related_field_types = { 492 | "assessment_items": partial(self._session.class_by_name, name="AssessmentItem"), 493 | } 494 | if self.has_key("items"): 495 | self.assessment_items = self["items"] 496 | del self["items"] 497 | 498 | class AssessmentItem(APIModel): 499 | """ 500 | A class to lazily load assessment item data for Perseus Exercise questions. 501 | """ 502 | 503 | base_url = "/assessment_items" 504 | 505 | def __init__(self, *args, **kwargs): 506 | 507 | super(AssessmentItem, self).__init__(*args, **kwargs) 508 | 509 | class Tag(APIModel): 510 | """ 511 | A class for tags for Perseus Assessment Items. 512 | """ 513 | 514 | base_url = "/assessment_items/tags" 515 | 516 | class Badge(APIModel): 517 | 518 | base_url = "/badges" 519 | 520 | def __init__(self, *args, **kwargs): 521 | 522 | super(Badge, self).__init__(*args, **kwargs) 523 | 524 | self._related_field_types = { 525 | "user_badges": self._session.class_by_kind, 526 | } 527 | 528 | 529 | class BadgeCategory(APIModel): 530 | pass 531 | 532 | 533 | class APIAuthModel(APIModel): 534 | 535 | def __getattr__(self, name): 536 | # Added to avoid infinite recursion during authentication 537 | if name == "_session": 538 | return super(APIAuthModel, self).__getattr__(name) 539 | elif self._session.require_authentication(): 540 | return super(APIAuthModel, self).__getattr__(name) 541 | 542 | # TODO: Add API_url function to add "?userID=" + user_id to each item 543 | # Check that classes other than User have user_id field. 544 | 545 | 546 | class User(APIAuthModel): 547 | 548 | base_url = "/user" 549 | 550 | _API_attributes = { 551 | "videos": "/videos", 552 | "exercises": "/exercises", 553 | "students": "/students", 554 | } 555 | 556 | def __init__(self, *args, **kwargs): 557 | 558 | super(User, self).__init__(*args, **kwargs) 559 | 560 | self._related_field_types = { 561 | "videos": partial(self._session.class_by_name, name="UserVideo"), 562 | "exercises": partial(self._session.class_by_name, name="UserExercise"), 563 | "students": partial(self._session.class_by_name, name="User"), 564 | } 565 | 566 | 567 | class UserExercise(APIAuthModel): 568 | 569 | base_url = "/user/exercises" 570 | 571 | _API_attributes = { 572 | "log": "/log", 573 | "followup_exercises": "/followup_exercises", 574 | } 575 | 576 | def __init__(self, *args, **kwargs): 577 | 578 | super(UserExercise, self).__init__(*args, **kwargs) 579 | 580 | self._related_field_types = { 581 | "exercise_model": self._session.class_by_kind, 582 | "followup_exercises": self._session.class_by_kind, 583 | "log": partial(self._session.class_by_name, name="ProblemLog"), 584 | } 585 | 586 | 587 | class UserVideo(APIAuthModel): 588 | base_url = "/user/videos" 589 | 590 | _API_attributes = { 591 | "log": "/log", 592 | } 593 | 594 | def __init__(self, *args, **kwargs): 595 | 596 | super(UserVideo, self).__init__(*args, **kwargs) 597 | 598 | self._related_field_types = { 599 | "video": self._session.class_by_kind, 600 | "log": partial(self._session.class_by_name, name="VideoLog"), 601 | } 602 | 603 | 604 | class UserBadge(APIAuthModel): 605 | pass 606 | 607 | # ProblemLog and VideoLog API calls return multiple entities in a list 608 | 609 | 610 | class ProblemLog(APIAuthModel): 611 | pass 612 | 613 | 614 | class VideoLog(APIAuthModel): 615 | pass 616 | 617 | 618 | class Topic(APIModel): 619 | 620 | base_url = "/topic" 621 | 622 | def __init__(self, *args, **kwargs): 623 | 624 | super(Topic, self).__init__(*args, **kwargs) 625 | 626 | self._related_field_types = { 627 | "children": self._session.class_by_kind, 628 | } 629 | 630 | class Playlist(APIModel): 631 | 632 | base_url = "/playlists" 633 | 634 | def __init__(self, *args, **kwargs): 635 | 636 | super(Playlist, self).__init__(*args, **kwargs) 637 | 638 | self._related_field_types = { 639 | "children": self._session.class_by_kind, 640 | } 641 | 642 | 643 | class Separator(APIModel): 644 | pass 645 | 646 | 647 | class Scratchpad(APIModel): 648 | base_url = "/show_scratchpad" 649 | 650 | 651 | def __init__(self, *args, **kwargs): 652 | 653 | super(Scratchpad, self).__init__(*args, **kwargs) 654 | 655 | self._api_version = "internal" 656 | 657 | data = self.pop("scratchpad", {}) 658 | 659 | self.update(data) 660 | 661 | 662 | 663 | class Article(APIModel): 664 | pass 665 | 666 | 667 | class Video(APIModel): 668 | 669 | base_url = "/videos" 670 | 671 | _API_attributes = {"related_exercises": "/exercises"} 672 | 673 | def __init__(self, *args, **kwargs): 674 | 675 | super(Video, self).__init__(*args, **kwargs) 676 | 677 | self._related_field_types = { 678 | "related_exercises": self._session.class_by_kind, 679 | } 680 | 681 | 682 | # kind_to_class_map maps from the kinds of data found in the topic tree, 683 | # and other nested data structures to particular classes. 684 | # If Khan Academy add any new types of data to topic tree, this will break 685 | # the topic tree rendering. 686 | 687 | 688 | kind_to_class_map = { 689 | "Video": Video, 690 | "Exercise": Exercise, 691 | "Topic": Topic, 692 | "Separator": Separator, 693 | "Scratchpad": Scratchpad, 694 | "Article": Article, 695 | "User": User, 696 | "UserData": User, 697 | "UserBadge": UserBadge, 698 | "UserVideo": UserVideo, 699 | "UserExercise": UserExercise, 700 | "ProblemLog": ProblemLog, 701 | "VideoLog": VideoLog, 702 | "Playlist": Playlist, 703 | "ProblemType": ProblemType, 704 | "AssessmentItem": AssessmentItem, 705 | "AssessmentItemTag": Tag, 706 | } 707 | 708 | 709 | # Different API endpoints use different attributes as the id, depending on the kind of the item. 710 | # This map defines the id to use for API calls, depending on the kind of 711 | # the item. 712 | 713 | 714 | kind_to_id_map = { 715 | "Video": partial(n_deep, names=["readable_id"]), 716 | "Exercise": partial(n_deep, names=["name"]), 717 | "Topic": partial(n_deep, names=["slug"]), 718 | "Playlist": partial(n_deep, names=["slug"]), 719 | # "User": partial(n_deep, names=["user_id"]), 720 | # "UserData": partial(n_deep, names=["user_id"]), 721 | "UserExercise": partial(n_deep, names=["exercise"]), 722 | "UserVideo": partial(n_deep, names=["video", "youtube_id"]), 723 | "ProblemLog": partial(n_deep, names=["exercise"]), 724 | "VideoLog": partial(n_deep, names=["video_title"]), 725 | } 726 | 727 | kind_to_get_key_map = { 728 | "User": "user_id", 729 | "UserData": "user_id", 730 | "UserExercise": "user", 731 | "UserVideo": "user", 732 | } 733 | 734 | get_key_to_get_param_map = { 735 | "user_id": "userId", 736 | "user": "username", 737 | } 738 | 739 | id_to_kind_map = {value: key for key, value in kind_to_id_map.items()} 740 | 741 | if __name__ == "__main__": 742 | # print t.name 743 | # print t.children 744 | # print t.children[0].__class__ 745 | # print t.children[1].__class__ 746 | # print api_call("v1", "/videos"); 747 | # print api_call("nothing"); 748 | # Video.get_video("adding-subtracting-negative-numbers") 749 | # Video.get_video("C38B33ZywWs") 750 | Topic.get_tree() 751 | -------------------------------------------------------------------------------- /api_models_test.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from api_models import * 3 | 4 | class ApiCallExerciseTest(unittest.TestCase): 5 | """Performs an API call to fetch exercises and verifies the result. 6 | 7 | Attributes: 8 | exercises_list_object: A list of Exercise objects. 9 | exercise_object: An Exercise object that was specifically requested. 10 | """ 11 | 12 | def setUp(self): 13 | """Prepares the objects that will be tested.""" 14 | self.exercises_list_object = Khan().get_exercises() 15 | self.exercise_object = Khan().get_exercise("logarithms_1") 16 | 17 | def test_get_exercises(self): 18 | """Tests if the result is an empty list or if it is a list of Exercise objects.""" 19 | if not self.exercises_list_object: 20 | self.assertListEqual(self.exercises_list_object, []) 21 | else: 22 | for obj in self.exercises_list_object: 23 | self.assertIsInstance(obj, Exercise) 24 | 25 | def test_get_exercise(self): 26 | """Tests if the result object contains the requested Exercise ID.""" 27 | self.assertEqual("logarithms_1", self.exercise_object.name) 28 | 29 | def test_get_exercise_related_videos(self): 30 | """Tests if the result is and empty list or if it is a list of Video objects.""" 31 | if not self.exercise_object.related_videos: 32 | self.assertListEqual(self.exercise_object.related_videos, []) 33 | else: 34 | for obj in self.exercise_object.related_videos: 35 | self.assertIsInstance(obj, Video) 36 | 37 | def test_get_exercise_followup_exercises(self): 38 | """Tests if the result is and empty list or if it is a list of Exercise objects.""" 39 | if not self.exercise_object.followup_exercises: 40 | self.assertListEqual(self.exercise_object.followup_exercises, []) 41 | else: 42 | for obj in self.exercise_object.followup_exercises: 43 | self.assertIsInstance(obj, Exercise) 44 | 45 | 46 | class ApiCallBadgeTest(unittest.TestCase): 47 | """Performs an API call to fetch badges and verifies the result. 48 | 49 | Attributes: 50 | badges_list_object: A list of Badge objects. 51 | badges_category_object: A BadgeCategory object that was specifically requested. 52 | badges_category_list_object: A list of BadgeCategory objects. 53 | """ 54 | 55 | def setUp(self): 56 | """Prepares the objects that will be tested.""" 57 | self.badges_list_object = Khan().get_badges() 58 | self.badges_category_object = Khan().get_badge_category(1) 59 | self.badges_category_list_object = Khan().get_badge_category() 60 | 61 | def test_get_badges(self): 62 | """Tests if the result is an empty list or if it is a list of Bagde objects.""" 63 | if not self.badges_list_object: 64 | self.assertListEqual(self.badges_list_object, []) 65 | else: 66 | for obj in self.badges_list_object: 67 | self.assertIsInstance(obj, Badge) 68 | 69 | def test_get_category(self): 70 | """Tests if the result object contains the requested Badge category.""" 71 | self.assertEqual(self.badges_category_object.category, 1) 72 | 73 | def test_get_category_list(self): 74 | """Tests if the result is an empty list or if it is a list of BadgeCategory objects.""" 75 | if not self.badges_category_list_object: 76 | self.assertListEqual(self.badges_category_list_object, []) 77 | else: 78 | for obj in self.badges_category_list_object: 79 | self.assertIsInstance(obj, BadgeCategory) 80 | 81 | 82 | 83 | class ApiCallUserTest(unittest.TestCase): 84 | """Performs an API call to fetch user data and verifies the result. 85 | 86 | This test will require login in Khan Academy. 87 | 88 | Attributes: 89 | user_object: An User object that is created after the user login. 90 | badges_object: A Badge object that cointains UserBadge objects if the user is logged in. 91 | """ 92 | 93 | def setUp(self): 94 | """Prepares the objects that will be tested.""" 95 | self.user_object = Khan().get_user() 96 | self.badges_object = Khan().get_badges() 97 | 98 | def test_get_user(self): 99 | """Tests if the result is an instance of User. The object is created if the result of the API call is a success.""" 100 | self.assertIsInstance(self.user_object, User) 101 | 102 | def test_get_user_videos(self): 103 | """Tests if the result is an empty list or if it is a list of UserVideo objects. 104 | For each UserVideo object check if log contains VideoLog objects. 105 | """ 106 | if not self.user_object.videos: 107 | self.assertListEqual(self.user_object.videos, []) 108 | else: 109 | for obj in self.user_object.videos: 110 | self.assertIsInstance(obj, UserVideo) 111 | if not obj.log: 112 | self.assertListEqual(obj.log, []) 113 | else: 114 | for l_obj in obj.log: 115 | self.assertIsInstance(l_obj, VideoLog) 116 | 117 | def test_get_user_exercises(self): 118 | """Tests if the result is an empty list or if it is a list of UserExercise objects. 119 | For each UserExercise object, checks if log attribute only contains ProblemLog objects 120 | and if followup_exercises attribute only contains UserExercise objects. 121 | """ 122 | if not self.user_object.exercises: 123 | self.assertListEqual(self.user_object.exercises, []) 124 | else: 125 | for obj in self.user_object.exercises: 126 | self.assertIsInstance(obj, UserExercise) 127 | if not obj.log: 128 | self.assertListEqual(obj.log, []) 129 | else: 130 | for l_obj in obj.log: 131 | self.assertIsInstance(l_obj, ProblemLog) 132 | if not obj.followup_exercises: 133 | self.assertListEqual(obj.followup_exercises, []) 134 | else: 135 | for f_obj in obj.followup_exercises: 136 | self.assertIsInstance(f_obj, UserExercise) 137 | 138 | def test_get_user_badges(self): 139 | """Tests if the result is an empty list or if it is a list of Badge objects. 140 | Then for each Badge, if it contains the user_badges key, it must be an instance of User Badges. 141 | """ 142 | if not self.badges_object: 143 | self.assertListEqual(self.badges_object, []) 144 | else: 145 | for obj in self.badges_object: 146 | if not obj.__contains__("user_badges"): 147 | continue 148 | else: 149 | for u_obj in obj.user_badges: 150 | self.assertIsInstance(u_obj, UserBadge) 151 | 152 | 153 | class ApiCallTopicTest(unittest.TestCase): 154 | """Performs an API call to fetch Topic data and verifies the result. 155 | 156 | Attributes: 157 | topic_tree_object: A Topic object that represents the entire Topic tree. 158 | topic_subtree_object: A Topic object that was specifically requested. It represents a subtree. 159 | """ 160 | 161 | def setUp(self): 162 | """Prepares the objects that will be tested.""" 163 | self.topic_tree_object = Khan().get_topic_tree() 164 | self.topic_subtree_object = Khan().get_topic_tree("addition-subtraction") 165 | self.topic_exercises_list_object = Khan().get_topic_exercises("addition-subtraction") 166 | self.topic_videos_list_object = Khan().get_topic_videos("addition-subtraction") 167 | 168 | def test_get_tree(self): 169 | """Tests if the result is an instance of Topic.""" 170 | self.assertIsInstance(self.topic_tree_object, Topic) 171 | 172 | def test_get_subtree(self): 173 | """Tests if the result object contains the requested topic slug.""" 174 | self.assertEqual("addition-subtraction", self.topic_subtree_object.slug) 175 | 176 | def test_get_topic_exercises(self): 177 | """Tests if the result is an empty list or if it is a list of Exercise objects.""" 178 | if not self.topic_exercises_list_object: 179 | self.assertListEqual(self.topic_exercises_list_object, []) 180 | else: 181 | for obj in self.topic_exercises_list_object: 182 | self.assertIsInstance(obj, Exercise) 183 | 184 | def test_get_topic_videos(self): 185 | """Tests if the result is an emtpy list or if it is a list of Video objects.""" 186 | if not self.topic_videos_list_object: 187 | self.assertListEqual(self.topic_videos_list_object, []) 188 | else: 189 | for obj in self.topic_videos_list_object: 190 | self.assertIsInstance(obj, Video) 191 | 192 | 193 | 194 | class ApiCallVideoTest(unittest.TestCase): 195 | """Performs an API call to fetch video data and verifies the result. 196 | 197 | Attributes: 198 | video_object: A Video object that was specifically requested. 199 | """ 200 | 201 | def setUp(self): 202 | """Prepares the objects that will be tested.""" 203 | self.video_object = Khan().get_video("adding-subtracting-negative-numbers") 204 | 205 | def test_get_video(self): 206 | """Tests if the result object contains the requested video readable id.""" 207 | self.assertEqual("adding-subtracting-negative-numbers", self.video_object.readable_id) 208 | 209 | 210 | 211 | def prepare_suites_from_test_cases(case_class_list): 212 | """ 213 | This function prepares a list of suites to be tested. 214 | """ 215 | test_suites = [] 216 | for cls in case_class_list: 217 | test_suites.append(unittest.TestLoader().loadTestsFromTestCase(cls)) 218 | return test_suites 219 | 220 | 221 | 222 | # "test_cases" contains the classes that will be tested. 223 | # Add or remove test cases as needed. 224 | test_cases = [ 225 | 226 | ApiCallExerciseTest, 227 | ApiCallBadgeTest, 228 | ApiCallUserTest, 229 | ApiCallTopicTest, 230 | ApiCallVideoTest, 231 | 232 | ] 233 | 234 | # Prepares a set of suites. 235 | all_tests = unittest.TestSuite(prepare_suites_from_test_cases(test_cases)) 236 | 237 | # Runs all tests on suites passed as an argument. 238 | unittest.TextTestRunner(verbosity=2).run(all_tests) 239 | 240 | -------------------------------------------------------------------------------- /oauth.py: -------------------------------------------------------------------------------- 1 | """ 2 | The MIT License 3 | 4 | Copyright (c) 2007 Leah Culver 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | """ 24 | 25 | import logging 26 | logger = logging.getLogger() 27 | 28 | import cgi 29 | import urllib 30 | import time 31 | import random 32 | import urlparse 33 | import hmac 34 | import binascii 35 | 36 | 37 | VERSION = '1.0' # Hi Blaine! 38 | HTTP_METHOD = 'GET' 39 | SIGNATURE_METHOD = 'PLAINTEXT' 40 | 41 | 42 | class OAuthError(RuntimeError): 43 | """Generic exception class.""" 44 | def __init__(self, message='OAuth error occured.'): 45 | self.message = message 46 | 47 | def build_authenticate_header(realm=''): 48 | """Optional WWW-Authenticate header (401 error)""" 49 | return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} 50 | 51 | def escape(s): 52 | """Escape a URL including any /.""" 53 | return urllib.quote(s, safe='~') 54 | 55 | def _utf8_str(s): 56 | """Convert unicode to utf-8.""" 57 | if isinstance(s, unicode): 58 | return s.encode("utf-8") 59 | else: 60 | return str(s) 61 | 62 | def generate_timestamp(): 63 | """Get seconds since epoch (UTC).""" 64 | return int(time.time()) 65 | 66 | def generate_nonce(length=8): 67 | """Generate pseudorandom number.""" 68 | return ''.join([str(random.randint(0, 9)) for i in range(length)]) 69 | 70 | def generate_verifier(length=8): 71 | """Generate pseudorandom number.""" 72 | return ''.join([str(random.randint(0, 9)) for i in range(length)]) 73 | 74 | 75 | class OAuthConsumer(object): 76 | """Consumer of OAuth authentication. 77 | 78 | OAuthConsumer is a data type that represents the identity of the Consumer 79 | via its shared secret with the Service Provider. 80 | 81 | """ 82 | key = None 83 | secret = None 84 | 85 | def __init__(self, key, secret): 86 | self.key = key 87 | self.secret = secret 88 | 89 | 90 | class OAuthToken(object): 91 | """OAuthToken is a data type that represents an End User via either an access 92 | or request token. 93 | 94 | key -- the token 95 | secret -- the token secret 96 | 97 | """ 98 | key = None 99 | secret = None 100 | callback = None 101 | callback_confirmed = None 102 | verifier = None 103 | 104 | def __init__(self, key, secret): 105 | self.key = key 106 | self.secret = secret 107 | 108 | def set_callback(self, callback): 109 | self.callback = callback 110 | self.callback_confirmed = 'true' 111 | 112 | def set_verifier(self, verifier=None): 113 | if verifier is not None: 114 | self.verifier = verifier 115 | else: 116 | self.verifier = generate_verifier() 117 | 118 | def get_callback_url(self): 119 | if self.callback and self.verifier: 120 | # Append the oauth_verifier. 121 | parts = urlparse.urlparse(self.callback) 122 | scheme, netloc, path, params, query, fragment = parts[:6] 123 | if query: 124 | query = '%s&oauth_verifier=%s' % (query, self.verifier) 125 | else: 126 | query = 'oauth_verifier=%s' % self.verifier 127 | return urlparse.urlunparse((scheme, netloc, path, params, 128 | query, fragment)) 129 | return self.callback 130 | 131 | def to_string(self): 132 | data = { 133 | 'oauth_token': self.key, 134 | 'oauth_token_secret': self.secret, 135 | } 136 | if self.callback_confirmed is not None: 137 | data['oauth_callback_confirmed'] = self.callback_confirmed 138 | return urllib.urlencode(data) 139 | 140 | def from_string(s): 141 | """ Returns a token from something like: 142 | oauth_token_secret=xxx&oauth_token=xxx 143 | """ 144 | params = cgi.parse_qs(s, keep_blank_values=False) 145 | key = params['oauth_token'][0] 146 | secret = params['oauth_token_secret'][0] 147 | token = OAuthToken(key, secret) 148 | try: 149 | token.callback_confirmed = params['oauth_callback_confirmed'][0] 150 | except KeyError: 151 | pass # 1.0, no callback confirmed. 152 | return token 153 | from_string = staticmethod(from_string) 154 | 155 | def __str__(self): 156 | return self.to_string() 157 | 158 | 159 | class OAuthRequest(object): 160 | """OAuthRequest represents the request and can be serialized. 161 | 162 | OAuth parameters: 163 | - oauth_consumer_key 164 | - oauth_token 165 | - oauth_signature_method 166 | - oauth_signature 167 | - oauth_timestamp 168 | - oauth_nonce 169 | - oauth_version 170 | - oauth_verifier 171 | ... any additional parameters, as defined by the Service Provider. 172 | """ 173 | parameters = None # OAuth parameters. 174 | http_method = HTTP_METHOD 175 | http_url = None 176 | version = VERSION 177 | 178 | def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None): 179 | self.http_method = http_method 180 | self.http_url = http_url 181 | self.parameters = parameters or {} 182 | 183 | def set_parameter(self, parameter, value): 184 | self.parameters[parameter] = value 185 | 186 | def get_parameter(self, parameter): 187 | try: 188 | return self.parameters[parameter] 189 | except: 190 | raise OAuthError('Parameter not found: %s' % parameter) 191 | 192 | def _get_timestamp_nonce(self): 193 | return self.get_parameter('oauth_timestamp'), self.get_parameter( 194 | 'oauth_nonce') 195 | 196 | def get_nonoauth_parameters(self): 197 | """Get any non-OAuth parameters.""" 198 | parameters = {} 199 | for k, v in self.parameters.iteritems(): 200 | # Ignore oauth parameters. 201 | if k.find('oauth_') < 0: 202 | parameters[k] = v 203 | return parameters 204 | 205 | def to_header(self, realm=''): 206 | """Serialize as a header for an HTTPAuth request.""" 207 | auth_header = 'OAuth realm="%s"' % realm 208 | # Add the oauth parameters. 209 | if self.parameters: 210 | for k, v in self.parameters.iteritems(): 211 | if k[:6] == 'oauth_': 212 | auth_header += ', %s="%s"' % (k, escape(str(v))) 213 | return {'Authorization': auth_header} 214 | 215 | def to_postdata(self): 216 | """Serialize as post data for a POST request.""" 217 | return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) \ 218 | for k, v in self.parameters.iteritems()]) 219 | 220 | def to_url(self): 221 | """Serialize as a URL for a GET request.""" 222 | return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata()) 223 | 224 | def get_normalized_parameters(self): 225 | """Return a string that contains the parameters that must be signed.""" 226 | params = self.parameters 227 | try: 228 | # Exclude the signature if it exists. 229 | del params['oauth_signature'] 230 | except: 231 | pass 232 | # Escape key values before sorting. 233 | key_values = [(escape(_utf8_str(k)), escape(_utf8_str(v))) \ 234 | for k,v in params.items()] 235 | # Sort lexicographically, first after key, then after value. 236 | key_values.sort() 237 | # Combine key value pairs into a string. 238 | return '&'.join(['%s=%s' % (k, v) for k, v in key_values]) 239 | 240 | def get_normalized_http_method(self): 241 | """Uppercases the http method.""" 242 | return self.http_method.upper() 243 | 244 | def get_normalized_http_url(self): 245 | """Parses the URL and rebuilds it to be scheme://host/path.""" 246 | parts = urlparse.urlparse(self.http_url) 247 | scheme, netloc, path = parts[:3] 248 | # Exclude default port numbers. 249 | if scheme == 'http' and netloc[-3:] == ':80': 250 | netloc = netloc[:-3] 251 | elif scheme == 'https' and netloc[-4:] == ':443': 252 | netloc = netloc[:-4] 253 | return '%s://%s%s' % (scheme, netloc, path) 254 | 255 | def sign_request(self, signature_method, consumer, token): 256 | """Set the signature parameter to the result of build_signature.""" 257 | # Set the signature method. 258 | self.set_parameter('oauth_signature_method', 259 | signature_method.get_name()) 260 | # Set the signature. 261 | self.set_parameter('oauth_signature', 262 | self.build_signature(signature_method, consumer, token)) 263 | 264 | def build_signature(self, signature_method, consumer, token): 265 | """Calls the build signature method within the signature method.""" 266 | return signature_method.build_signature(self, consumer, token) 267 | 268 | def from_request(http_method, http_url, headers=None, parameters=None, 269 | query_string=None): 270 | """Combines multiple parameter sources.""" 271 | if parameters is None: 272 | parameters = {} 273 | 274 | # Headers 275 | if headers and 'Authorization' in headers: 276 | auth_header = headers['Authorization'] 277 | # Check that the authorization header is OAuth. 278 | if auth_header[:6] == 'OAuth ': 279 | auth_header = auth_header[6:] 280 | try: 281 | # Get the parameters from the header. 282 | header_params = OAuthRequest._split_header(auth_header) 283 | parameters.update(header_params) 284 | except: 285 | raise OAuthError('Unable to parse OAuth parameters from ' 286 | 'Authorization header.') 287 | 288 | # GET or POST query string. 289 | if query_string: 290 | query_params = OAuthRequest._split_url_string(query_string) 291 | parameters.update(query_params) 292 | 293 | # URL parameters. 294 | param_str = urlparse.urlparse(http_url)[4] # query 295 | url_params = OAuthRequest._split_url_string(param_str) 296 | parameters.update(url_params) 297 | 298 | if parameters: 299 | return OAuthRequest(http_method, http_url, parameters) 300 | 301 | return None 302 | from_request = staticmethod(from_request) 303 | 304 | def from_consumer_and_token(oauth_consumer, token=None, 305 | callback=None, verifier=None, http_method=HTTP_METHOD, 306 | http_url=None, parameters=None): 307 | if not parameters: 308 | parameters = {} 309 | 310 | defaults = { 311 | 'oauth_consumer_key': oauth_consumer.key, 312 | 'oauth_timestamp': generate_timestamp(), 313 | 'oauth_nonce': generate_nonce(), 314 | 'oauth_version': OAuthRequest.version, 315 | } 316 | 317 | defaults.update(parameters) 318 | parameters = defaults 319 | 320 | if token: 321 | parameters['oauth_token'] = token.key 322 | if token.callback: 323 | parameters['oauth_callback'] = token.callback 324 | # 1.0a support for verifier. 325 | if verifier: 326 | parameters['oauth_verifier'] = verifier 327 | elif callback: 328 | # 1.0a support for callback in the request token request. 329 | parameters['oauth_callback'] = callback 330 | 331 | return OAuthRequest(http_method, http_url, parameters) 332 | from_consumer_and_token = staticmethod(from_consumer_and_token) 333 | 334 | def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD, 335 | http_url=None, parameters=None): 336 | if not parameters: 337 | parameters = {} 338 | 339 | parameters['oauth_token'] = token.key 340 | 341 | if callback: 342 | parameters['oauth_callback'] = callback 343 | 344 | return OAuthRequest(http_method, http_url, parameters) 345 | from_token_and_callback = staticmethod(from_token_and_callback) 346 | 347 | def _split_header(header): 348 | """Turn Authorization: header into parameters.""" 349 | params = {} 350 | parts = header.split(',') 351 | for param in parts: 352 | # Ignore realm parameter. 353 | if param.find('realm') > -1: 354 | continue 355 | # Remove whitespace. 356 | param = param.strip() 357 | # Split key-value. 358 | param_parts = param.split('=', 1) 359 | # Remove quotes and unescape the value. 360 | params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"')) 361 | return params 362 | _split_header = staticmethod(_split_header) 363 | 364 | def _split_url_string(param_str): 365 | """Turn URL string into parameters.""" 366 | parameters = cgi.parse_qs(param_str, keep_blank_values=False) 367 | for k, v in parameters.iteritems(): 368 | parameters[k] = urllib.unquote(v[0]) 369 | return parameters 370 | _split_url_string = staticmethod(_split_url_string) 371 | 372 | class OAuthServer(object): 373 | """A worker to check the validity of a request against a data store.""" 374 | timestamp_threshold = 300 # In seconds, five minutes. 375 | version = VERSION 376 | signature_methods = None 377 | data_store = None 378 | 379 | def __init__(self, data_store=None, signature_methods=None): 380 | self.data_store = data_store 381 | self.signature_methods = signature_methods or {} 382 | 383 | def set_data_store(self, data_store): 384 | self.data_store = data_store 385 | 386 | def get_data_store(self): 387 | return self.data_store 388 | 389 | def add_signature_method(self, signature_method): 390 | self.signature_methods[signature_method.get_name()] = signature_method 391 | return self.signature_methods 392 | 393 | def fetch_request_token(self, oauth_request): 394 | """Processes a request_token request and returns the 395 | request token on success. 396 | """ 397 | try: 398 | # Get the request token for authorization. 399 | token = self._get_token(oauth_request, 'request') 400 | except OAuthError: 401 | # No token required for the initial token request. 402 | version = self._get_version(oauth_request) 403 | consumer = self._get_consumer(oauth_request) 404 | try: 405 | callback = self.get_callback(oauth_request) 406 | except OAuthError: 407 | callback = None # 1.0, no callback specified. 408 | self._check_signature(oauth_request, consumer, None) 409 | # Fetch a new token. 410 | token = self.data_store.fetch_request_token(consumer, callback) 411 | return token 412 | 413 | def fetch_access_token(self, oauth_request): 414 | logger.debug("!!! IN OAuthServer.fetch_access_token OAuth Params: %s"%oauth_request.parameters) 415 | 416 | """Processes an access_token request and returns the 417 | access token on success. 418 | """ 419 | version = self._get_version(oauth_request) 420 | consumer = self._get_consumer(oauth_request) 421 | try: 422 | verifier = self._get_verifier(oauth_request) 423 | except OAuthError: 424 | verifier = None 425 | # Get the request token. 426 | token = self._get_token(oauth_request, 'request') 427 | self._check_signature(oauth_request, consumer, token) 428 | new_token = self.data_store.fetch_access_token(consumer, token, verifier) 429 | return new_token 430 | 431 | def verify_request(self, oauth_request): 432 | """Verifies an api call and checks all the parameters.""" 433 | # -> consumer and token 434 | version = self._get_version(oauth_request) 435 | consumer = self._get_consumer(oauth_request) 436 | # Get the access token. 437 | token = self._get_token(oauth_request, 'access') 438 | self._check_signature(oauth_request, consumer, token) 439 | parameters = oauth_request.get_nonoauth_parameters() 440 | return consumer, token, parameters 441 | 442 | def authorize_token(self, token, user): 443 | """Authorize a request token.""" 444 | return self.data_store.authorize_request_token(token, user) 445 | 446 | def get_callback(self, oauth_request): 447 | """Get the callback URL.""" 448 | return oauth_request.get_parameter('oauth_callback') 449 | 450 | def build_authenticate_header(self, realm=''): 451 | """Optional support for the authenticate header.""" 452 | return {'WWW-Authenticate': 'OAuth realm="%s"' % realm} 453 | 454 | def _get_version(self, oauth_request): 455 | """Verify the correct version request for this server.""" 456 | try: 457 | version = oauth_request.get_parameter('oauth_version') 458 | except: 459 | version = VERSION 460 | if version and version != self.version: 461 | raise OAuthError('OAuth version %s not supported.' % str(version)) 462 | return version 463 | 464 | def _get_signature_method(self, oauth_request): 465 | """Figure out the signature with some defaults.""" 466 | try: 467 | signature_method = oauth_request.get_parameter( 468 | 'oauth_signature_method') 469 | except: 470 | signature_method = SIGNATURE_METHOD 471 | try: 472 | # Get the signature method object. 473 | signature_method = self.signature_methods[signature_method] 474 | except: 475 | signature_method_names = ', '.join(self.signature_methods.keys()) 476 | raise OAuthError('Signature method %s not supported try one of the ' 477 | 'following: %s' % (signature_method, signature_method_names)) 478 | 479 | return signature_method 480 | 481 | def _get_consumer(self, oauth_request): 482 | consumer_key = oauth_request.get_parameter('oauth_consumer_key') 483 | consumer = self.data_store.lookup_consumer(consumer_key) 484 | if not consumer: 485 | raise OAuthError('Invalid consumer.') 486 | return consumer 487 | 488 | def _get_token(self, oauth_request, token_type='access'): 489 | """Try to find the token for the provided request token key.""" 490 | token_field = oauth_request.get_parameter('oauth_token') 491 | token = self.data_store.lookup_token(token_type, token_field) 492 | if not token: 493 | raise OAuthError('Invalid %s token: %s' % (token_type, token_field)) 494 | return token 495 | 496 | def _get_verifier(self, oauth_request): 497 | return oauth_request.get_parameter('oauth_verifier') 498 | 499 | def _check_signature(self, oauth_request, consumer, token): 500 | timestamp, nonce = oauth_request._get_timestamp_nonce() 501 | self._check_timestamp(timestamp) 502 | self._check_nonce(consumer, token, nonce) 503 | signature_method = self._get_signature_method(oauth_request) 504 | try: 505 | signature = oauth_request.get_parameter('oauth_signature') 506 | except: 507 | raise OAuthError('Missing signature.') 508 | # Validate the signature. 509 | valid_sig = signature_method.check_signature(oauth_request, consumer, 510 | token, signature) 511 | if not valid_sig: 512 | key, base = signature_method.build_signature_base_string( 513 | oauth_request, consumer, token) 514 | logging.error("key: %s",key) 515 | logging.error("base: %s",base) 516 | raise OAuthError('Invalid signature. Expected signature base ' 517 | 'string: %s' % base) 518 | built = signature_method.build_signature(oauth_request, consumer, token) 519 | 520 | def _check_timestamp(self, timestamp): 521 | """Verify that timestamp is recentish.""" 522 | timestamp = int(timestamp) 523 | now = int(time.time()) 524 | lapsed = abs(now - timestamp) 525 | if lapsed > self.timestamp_threshold: 526 | raise OAuthError('Expired timestamp: given %d and now %s has a ' 527 | 'greater difference than threshold %d' % 528 | (timestamp, now, self.timestamp_threshold)) 529 | 530 | def _check_nonce(self, consumer, token, nonce): 531 | """Verify that the nonce is uniqueish.""" 532 | nonce = self.data_store.lookup_nonce(consumer, token, nonce) 533 | if nonce: 534 | raise OAuthError('Nonce already used: %s' % str(nonce)) 535 | 536 | 537 | class OAuthClient(object): 538 | """OAuthClient is a worker to attempt to execute a request.""" 539 | consumer = None 540 | token = None 541 | 542 | def __init__(self, oauth_consumer, oauth_token): 543 | self.consumer = oauth_consumer 544 | self.token = oauth_token 545 | 546 | def get_consumer(self): 547 | return self.consumer 548 | 549 | def get_token(self): 550 | return self.token 551 | 552 | def fetch_request_token(self, oauth_request): 553 | """-> OAuthToken.""" 554 | raise NotImplementedError 555 | 556 | def fetch_access_token(self, oauth_request): 557 | """-> OAuthToken.""" 558 | raise NotImplementedError 559 | 560 | def access_resource(self, oauth_request): 561 | """-> Some protected resource.""" 562 | raise NotImplementedError 563 | 564 | 565 | class OAuthDataStore(object): 566 | """A database abstraction used to lookup consumers and tokens.""" 567 | 568 | def lookup_consumer(self, key): 569 | """-> OAuthConsumer.""" 570 | raise NotImplementedError 571 | 572 | def lookup_token(self, oauth_consumer, token_type, token_token): 573 | """-> OAuthToken.""" 574 | raise NotImplementedError 575 | 576 | def lookup_nonce(self, oauth_consumer, oauth_token, nonce): 577 | """-> OAuthToken.""" 578 | raise NotImplementedError 579 | 580 | def fetch_request_token(self, oauth_consumer, oauth_callback): 581 | """-> OAuthToken.""" 582 | raise NotImplementedError 583 | 584 | def fetch_access_token(self, oauth_consumer, oauth_token, oauth_verifier): 585 | """-> OAuthToken.""" 586 | raise NotImplementedError 587 | 588 | def authorize_request_token(self, oauth_token, user): 589 | """-> OAuthToken.""" 590 | raise NotImplementedError 591 | 592 | 593 | class OAuthSignatureMethod(object): 594 | """A strategy class that implements a signature method.""" 595 | def get_name(self): 596 | """-> str.""" 597 | raise NotImplementedError 598 | 599 | def build_signature_base_string(self, oauth_request, oauth_consumer, oauth_token): 600 | """-> str key, str raw.""" 601 | raise NotImplementedError 602 | 603 | def build_signature(self, oauth_request, oauth_consumer, oauth_token): 604 | """-> str.""" 605 | raise NotImplementedError 606 | 607 | def check_signature(self, oauth_request, consumer, token, signature): 608 | built = self.build_signature(oauth_request, consumer, token) 609 | logging.info("Built signature: %s"%(built)) 610 | return built == signature 611 | 612 | 613 | class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod): 614 | 615 | def get_name(self): 616 | return 'HMAC-SHA1' 617 | 618 | def build_signature_base_string(self, oauth_request, consumer, token): 619 | sig = ( 620 | escape(oauth_request.get_normalized_http_method()), 621 | escape(oauth_request.get_normalized_http_url()), 622 | escape(oauth_request.get_normalized_parameters()), 623 | ) 624 | 625 | key = '%s&' % escape(consumer.secret) 626 | if token: 627 | key += escape(token.secret) 628 | raw = '&'.join(sig) 629 | return key, raw 630 | 631 | def build_signature(self, oauth_request, consumer, token): 632 | """Builds the base signature string.""" 633 | key, raw = self.build_signature_base_string(oauth_request, consumer, 634 | token) 635 | 636 | if isinstance(key, unicode): 637 | key = str(key) 638 | 639 | # HMAC object. 640 | try: 641 | import hashlib # 2.5 642 | hashed = hmac.new(key, raw, hashlib.sha1) 643 | except: 644 | import sha # Deprecated 645 | hashed = hmac.new(key, raw, sha) 646 | 647 | # Calculate the digest base 64. 648 | return binascii.b2a_base64(hashed.digest())[:-1] 649 | 650 | 651 | class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod): 652 | 653 | def get_name(self): 654 | return 'PLAINTEXT' 655 | 656 | def build_signature_base_string(self, oauth_request, consumer, token): 657 | """Concatenates the consumer key and secret.""" 658 | sig = '%s&' % escape(consumer.secret) 659 | if token: 660 | sig = sig + escape(token.secret) 661 | return sig, sig 662 | 663 | def build_signature(self, oauth_request, consumer, token): 664 | key, raw = self.build_signature_base_string(oauth_request, consumer, 665 | token) 666 | return key 667 | -------------------------------------------------------------------------------- /secrets.py.template: -------------------------------------------------------------------------------- 1 | CONSUMER_KEY = "" 2 | CONSUMER_SECRET = "" 3 | -------------------------------------------------------------------------------- /test_oauth_client.py: -------------------------------------------------------------------------------- 1 | #This file is a copy of https://github.com/Khan/khan-api/blob/master/examples/test_client/test_oauth_client.py 2 | 3 | import cgi 4 | import logging 5 | import urllib2 6 | import urlparse 7 | import webbrowser 8 | 9 | from oauth import OAuthConsumer, OAuthToken, OAuthRequest, OAuthSignatureMethod_HMAC_SHA1 10 | 11 | class TestOAuthClient(object): 12 | 13 | def __init__(self, server_url, consumer_key, consumer_secret): 14 | self.server_url = server_url 15 | self.consumer = OAuthConsumer(consumer_key, consumer_secret) 16 | 17 | def start_fetch_request_token(self, callback=None): 18 | oauth_request = OAuthRequest.from_consumer_and_token( 19 | self.consumer, 20 | callback=callback, 21 | http_url="%s/api/auth/request_token" % self.server_url 22 | ) 23 | 24 | oauth_request.sign_request(OAuthSignatureMethod_HMAC_SHA1(), self.consumer, None) 25 | webbrowser.open(oauth_request.to_url()) 26 | 27 | def fetch_access_token(self, request_token): 28 | 29 | oauth_request = OAuthRequest.from_consumer_and_token( 30 | self.consumer, 31 | token=request_token, 32 | verifier=request_token.verifier, 33 | http_url="%s/api/auth/access_token" % self.server_url 34 | ) 35 | 36 | oauth_request.sign_request(OAuthSignatureMethod_HMAC_SHA1(), self.consumer, request_token) 37 | 38 | response = get_response(oauth_request.to_url()) 39 | 40 | return OAuthToken.from_string(response) 41 | 42 | def access_resource(self, relative_url, access_token, method="GET"): 43 | 44 | full_url = self.server_url + relative_url 45 | url = urlparse.urlparse(full_url) 46 | query_params = cgi.parse_qs(url.query) 47 | for key in query_params: 48 | query_params[key] = query_params[key][0] 49 | 50 | oauth_request = OAuthRequest.from_consumer_and_token( 51 | self.consumer, 52 | token = access_token, 53 | http_url = full_url, 54 | parameters = query_params, 55 | http_method=method 56 | ) 57 | 58 | oauth_request.sign_request(OAuthSignatureMethod_HMAC_SHA1(), self.consumer, access_token) 59 | 60 | if method == "GET": 61 | response = get_response(oauth_request.to_url()) 62 | else: 63 | response = post_response(full_url, oauth_request.to_postdata()) 64 | 65 | return response.strip() 66 | 67 | def get_response(url): 68 | response = "" 69 | file = None 70 | try: 71 | file = urllib2.urlopen(url) 72 | response = file.read() 73 | finally: 74 | if file: 75 | file.close() 76 | 77 | return response 78 | 79 | def post_response(url, data): 80 | response = "" 81 | file = None 82 | try: 83 | file = urllib2.urlopen(url, data) 84 | response = file.read() 85 | finally: 86 | if file: 87 | file.close() 88 | 89 | return response 90 | --------------------------------------------------------------------------------