import base64 import json import os import sys import requests import tornado from nbconvert.exporters.export import export_by_name from notebook.base.handlers import IPythonHandler from tornado.web import HTTPError GITHUB_API_ROOT = "https://api.github.com" PY3 = sys.version_info[0] == 3 if PY3: string_types = (str,) else: string_types = (basestring,) # noqa request_session = requests.Session() def raise_error(msg): raise HTTPError(500, "ERROR: " + msg) def raise_github_error(msg): raise HTTPError(500, "ERROR: Github returned the following: " + msg) class BaseHandler(IPythonHandler): def initialize(self, oauth_client_id, oauth_client_secret): self.oauth_client_id = oauth_client_id self.oauth_client_secret = oauth_client_secret def request_access_token(self, access_code): "Request access token from GitHub" token_response = request_session.post( "https://github.com/login/oauth/access_token", data={ "client_id": self.oauth_client_id, "client_secret": self.oauth_client_secret, "code": access_code }, headers={"Accept": "application/json"}, ) return helper_request_access_token(token_response.json()) class GistHandler(BaseHandler): """Handler for saving and editing Gists. This handler will save out the notebook to GitHub gists in either a new Gist or it will create a new revision for a gist that already contains these two files. """ def get(self): # Extract access code access_code = extract_code_from_args(self.request.arguments) # Request access token from github access_token = self.request_access_token(access_code) # Extract notebook path nb_path = extract_notebook_path_from_args(self.request.arguments) # Extract file name filename, filename_no_ext = get_notebook_filename(nb_path) # Extract file contents given the path to the notebook notebook_output, python_output = get_notebook_contents(nb_path) # Prepare and our github request to create the new gist filename_with_py = filename_no_ext + ".py" gist_contents = { "description": filename_no_ext, "public": False, "files": { filename: {"filename": filename, "content": notebook_output}, filename_with_py: {"filename": filename_with_py, "content": python_output} } } # Get the authenticated user's matching gist (if available) match_id = find_existing_gist_by_name(filename, filename_with_py, access_token) # If no gist with this name exists yet, create a new gist if match_id is None: gist_response = create_new_gist(gist_contents, access_token) # If we have another gist with the same files, create a new revision # Note: The case where we have multiple gists with the files is handled # by find_existing_gist_by_name # This else catches the case where there is exactly 1 match else: gist_response = edit_existing_gist(gist_contents, match_id, access_token) gist_response_json = gist_response.json() verify_gist_response(gist_response_json) # If we get here, we are good and can redirect gist_url = gist_response_json.get("html_url", None) self.redirect(gist_url) class DownloadNotebookHandler(IPythonHandler): def post(self): # url and filename are sent in a JSON encoded blob post_data = tornado.escape.json_decode(self.request.body) nb_url = post_data['nb_url'] nb_name = base64.b64decode(post_data['nb_name']).decode('utf-8') force_download = post_data['force_download'] file_path = os.path.join(os.getcwd(), nb_name) if os.path.isfile(file_path): if not force_download: raise HTTPError(409, "ERROR: File already exists.") response = request_session.get(nb_url, stream=True) with open(file_path, 'wb') as fd: # TODO: check if this is a good chunk size for chunk in response.iter_content(1024): fd.write(chunk) self.write(nb_name) self.flush() class LoadGistHandler(BaseHandler): def get(self): # Extract access code access_code = extract_code_from_args(self.request.arguments) # Request access token from github access_token = self.request_access_token(access_code) github_headers = {"Accept": "application/json", "Authorization": "token " + access_token} response = request_session.get("https://api.github.com/gists", headers=github_headers) response_to_send = bytearray(response.text, 'utf-8') self.write("<script>var gists = '") self.write(base64.standard_b64encode(response_to_send)) self.write("';") self.write("window.opener.postMessage(gists, window.opener.location)") self.finish(";</script>") def extract_code_from_args(args): """ Extracts the access code from the arguments dictionary (given back from github) """ if args is None: raise_error("Couldn't extract GitHub authentication code " "from response") # TODO: Is there a case where the length of the error will be < 0? error = args.get("error_description", None) if error is not None: if len(error) >= 0: raise_github_error(error) else: raise_error("Something went wrong") access_code = args.get("code", None) # access_code is supposed to be a list with 1 thing in it if not isinstance(access_code, list) or access_code[0] is None or \ len(access_code) != 1 or len(access_code[0]) <= 0: raise_error("Couldn't extract GitHub authentication code from " "response") # If we get here, everything was good - no errors access_code = access_code[0].decode('ascii') return access_code # Extracts the notebook path from the arguments dictionary (given back # from github) def extract_notebook_path_from_args(args): if args is None: raise_error("Couldn't extract notebook path from response") error = args.get("error_description", None) if error is not None: if len(error) >= 0: raise_github_error(error) path_bytes = args.get("nb_path", None) # path_bytes is supposed to be a list with 1 thing in it if not isinstance(path_bytes, list) or path_bytes[0] \ is None or len(path_bytes) != 1 or len(path_bytes[0]) <= 0: raise_error("Couldn't extract notebook path from response") # If we get here, everything was good - no errors nb_path = base64.b64decode(path_bytes[0]).decode('utf-8').lstrip("/") return nb_path def helper_request_access_token(token_args): token_error = token_args.get("error_description", None) if token_error is not None: raise_github_error(token_error) # Extract token and other info from github response access_token = token_args.get("access_token", None) token_type = token_args.get("token_type", None) scope = token_args.get("scope", None) if access_token is None or token_type is None or scope is None: raise_error("Couldn't extract needed info from GitHub access " "token response") # If we get here everything is good return access_token # do not care about scope or token_type def get_notebook_filename(nb_path): if not isinstance(nb_path, string_types) or len(nb_path) == 0: raise_error("Problem with notebook file name") # Extract file names given path to notebook filename = os.path.basename(nb_path) ext_start_ind = filename.rfind(".") # TODO: is it possible to have a notebook without an extension? if ext_start_ind == -1: filename_no_ext = filename else: filename_no_ext = filename[:ext_start_ind] return filename, filename_no_ext def get_notebook_contents(nb_path): if not isinstance(nb_path, string_types) or len(nb_path) == 0: raise_error("Couldn't export notebook contents") # Extract file contents given the path to the notebook try: notebook_output, _ = export_by_name("notebook", nb_path) python_output, _ = export_by_name("python", nb_path) except OSError: # python 2 does not support FileNotFoundError raise_error("Couldn't export notebook contents") return (notebook_output, python_output) def find_existing_gist_by_name(nb_filename, py_filename, access_token): github_headers = {"Accept": "application/json", "Authorization": "token " + access_token} response = request_session.get(GITHUB_API_ROOT + "/gists", headers=github_headers) gist_args = response.json() return helper_find_existing_gist_by_name(gist_args, nb_filename, py_filename) def helper_find_existing_gist_by_name(gist_args, nb_filename, py_filename): match_counter = 0 match_id = None for gist in gist_args: gist_files = gist.get("files", None) if (gist_files is not None and nb_filename in gist_files and py_filename in gist_files): match_counter += 1 if "id" in gist: match_id = gist["id"] # TODO: This probably shouldn't actually be an error # Instead, we should ask the user which gist they meant? if match_counter > 1: raise_error("You had multiple gists with the same name as this " "notebook. Aborting.") # If we are here we have either 0 or 1 gists that match. return match_id def create_new_gist(gist_contents, access_token): github_headers = {"Accept": "application/json", "Authorization": "token " + access_token} gist_response = request_session.post( GITHUB_API_ROOT + "/gists", data=json.dumps(gist_contents), headers=github_headers, ) return gist_response def edit_existing_gist(gist_contents, gist_id, access_token): github_headers = {"Accept": "application/json", "Authorization": "token " + access_token} gist_response = request_session.patch( GITHUB_API_ROOT + "/gists/" + gist_id, data=json.dumps(gist_contents), headers=github_headers, ) return gist_response def verify_gist_response(gist_response_json): if gist_response_json is None: raise_error("Couldn't get the URL for the gist that was just updated") update_gist_error = gist_response_json.get("error_description", None) if update_gist_error is not None: raise_github_error(update_gist_error) gist_url = gist_response_json.get("html_url", None) if gist_url is None: raise_error("Couldn't get the URL for the gist that was just updated")