diff --git a/notebook/nbconvert/handlers.py b/notebook/nbconvert/handlers.py index 89995226e1..db001f0981 100644 --- a/notebook/nbconvert/handlers.py +++ b/notebook/nbconvert/handlers.py @@ -5,6 +5,7 @@ import io import os +import json import zipfile from tornado import web, escape @@ -15,6 +16,8 @@ path_regex, ) from nbformat import from_dict +import nbformat +from traitlets.config import Config from ipython_genutils.py3compat import cast_bytes from ipython_genutils import text @@ -41,16 +44,17 @@ def respond_zip(handler, name, output, resources): handler.set_attachment_header(zip_filename) handler.set_header('Content-Type', 'application/zip') - # Prepare the zip file - buffer = io.BytesIO() - zipf = zipfile.ZipFile(buffer, mode='w', compression=zipfile.ZIP_DEFLATED) - output_filename = os.path.splitext(name)[0] + resources['output_extension'] - zipf.writestr(output_filename, cast_bytes(output, 'utf-8')) - for filename, data in output_files.items(): - zipf.writestr(os.path.basename(filename), data) - zipf.close() - - handler.finish(buffer.getvalue()) + # create zip file + buff = io.BytesIO() + with zipfile.ZipFile(buff, mode='w', compression=zipfile.ZIP_STORED) as zipf: + output_filename = os.path.splitext(name)[0] + resources['output_extension'] + zipf.writestr(output_filename, cast_bytes(output, 'utf-8')) + for filename, data in output_files.items(): + zipf.writestr(filename, data) + + # pass zip file back + buff.seek(0) + handler.finish(buff.getvalue()) return True def get_exporter(format, **kwargs): @@ -74,14 +78,11 @@ def get_exporter(format, **kwargs): raise web.HTTPError(500, "Could not construct Exporter: %s" % e) class NbconvertFileHandler(IPythonHandler): - SUPPORTED_METHODS = ('GET',) @web.authenticated def get(self, format, path): - exporter = get_exporter(format, config=self.config, log=self.log) - path = path.strip('/') # If the notebook relates to a real file (default contents manager), # give its path to nbconvert. @@ -92,15 +93,12 @@ def get(self, format, path): ext_resources_dir = None model = self.contents_manager.get(path=path) + nb = model['content'] name = model['name'] + self.set_header('Last-Modified', model['last_modified']) if model['type'] != 'notebook': # not a notebook, redirect to files return FilesRedirectHandler.redirect_to_files(self, path) - - nb = model['content'] - - self.set_header('Last-Modified', model['last_modified']) - # create resources dictionary mod_date = model['last_modified'].strftime(text.date_format) nb_title = os.path.splitext(name)[0] @@ -110,7 +108,8 @@ def get(self, format, path): "name": nb_title, "modified_date": mod_date }, - "config_dir": self.application.settings['config_dir'] + "config_dir": self.application.settings['config_dir'], + "output_files_dir": nb_title+"_files", } if ext_resources_dir: @@ -140,16 +139,93 @@ def get(self, format, path): self.finish(output) + +class NbconvertConfigHandler(IPythonHandler): + SUPPORTED_METHODS = ('POST',) + + @web.authenticated + def post(self): + + json_content = self.get_json_body() + + c = Config(self.config) + + # config needs to be dict + config = Config(json.loads(json_content.get("config",{}))) + c.merge(config) + + # We're adhering to the content model laid out by the notebook data model + # descriptor: + # http://jupyter-notebook.readthedocs.io/en/latest/extending/contents.html#data-model + # validate notebook before converting + nb_contents = json_content["notebook_contents"] + try: + nbformat.validate(nb_contents["content"]) + except nbformat.ValidationError as e: + self.log.exception("notebook content was not a valid notebook: %s", e) + raise web.HTTPError(500, "notebook content was not a valid notebook: %s" % e) + + nb = nbformat.from_dict(nb_contents["content"]) + name = nb_contents["name"] + last_mod = nb_contents.get("modified_date","") + nb_title = os.path.splitext(name)[0] + + export_format = c.NbConvertApp.get("export_format","") + if not export_format: + try: + export_format= json_content["export_format"] + except KeyError: + raise web.HTTPError(500, "No format specified for export.") + + exporter = get_exporter(export_format, config=c, log=self.log) + + metadata = {} + metadata['name'] = nb_title + if last_mod: + metadata['modified_date'] = last_mod.strftime(text.date_format) + + resources_dict= { + "config_dir": self.application.settings['config_dir'], + "output_files_dir": nb_title+"_files", + "metadata": metadata + } + + try: + output, resources = exporter.from_notebook_node( + nb, + resources=resources_dict + ) + except Exception as e: + self.log.exception("nbconvert failed: %s", e) + raise web.HTTPError(500, "nbconvert failed: %s" % e) + + + if respond_zip(self, name, output, resources): + return + + # Force download if requested + if self.get_argument('download', 'false').lower() == 'true': + output_filename = nb_title + resources['output_extension'] + self.set_attachment_header(output_filename) + + # MIME type + if exporter.output_mimetype: + self.set_header('Content-Type', + '%s; charset=utf-8' % exporter.output_mimetype) + + self.finish(output) + + class NbconvertPostHandler(IPythonHandler): SUPPORTED_METHODS = ('POST',) @web.authenticated def post(self, format): - exporter = get_exporter(format, config=self.config) model = self.get_json_body() name = model.get('name', 'notebook.ipynb') nbnode = from_dict(model['content']) + exporter = get_exporter(format, config=self.config) try: output, resources = exporter.from_notebook_node(nbnode, resources={ @@ -178,6 +254,7 @@ def post(self, format): default_handlers = [ + (r"/nbconvert", NbconvertConfigHandler), (r"/nbconvert/%s" % _format_regex, NbconvertPostHandler), (r"/nbconvert/%s%s" % (_format_regex, path_regex), NbconvertFileHandler), diff --git a/notebook/nbconvert/tests/test_nbconvert_handlers.py b/notebook/nbconvert/tests/test_nbconvert_handlers.py index ebcfb4e9b8..27a75054e6 100644 --- a/notebook/nbconvert/tests/test_nbconvert_handlers.py +++ b/notebook/nbconvert/tests/test_nbconvert_handlers.py @@ -22,10 +22,8 @@ from base64 import encodestring as encodebytes - - class NbconvertAPI(object): - """Wrapper for nbconvert API calls.""" + """Wrapper for API calls to /nbconvert/.""" def __init__(self, request): self.request = request @@ -43,11 +41,29 @@ def from_file(self, format, path, name, download=False): def from_post(self, format, nbmodel): body = json.dumps(nbmodel) - return self._req('POST', format, body) + return self._req('POST', format, body=body) def list_formats(self): return self._req('GET', '') +class NbconvertConfigAPI(NbconvertAPI): + """Wrapper for API calls to /nbconvert""" + + def _req(self, verb, body=None, params=None): + response = self.request(verb, 'nbconvert', data=body, params=params) + response.raise_for_status() + return response + + def from_post(self, nbmodel, format="", config=None): + body = {} + body['notebook_contents'] = nbmodel + body['config'] = config + if format: + body['export_format'] = format + + return self._req('POST', body=json.dumps(body)) + + png_green_pixel = encodebytes(b'\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00' b'\x00\x00\x01\x00\x00x00\x01\x08\x02\x00\x00\x00\x90wS\xde\x00\x00\x00\x0cIDAT' b'\x08\xd7c\x90\xfb\xcf\x00\x00\x02\\\x01\x1e.~d\x87\x00\x00\x00\x00IEND\xaeB`\x82' @@ -56,7 +72,7 @@ def list_formats(self): class APITest(NotebookTestBase): def setUp(self): nbdir = self.notebook_dir - + if not os.path.isdir(pjoin(nbdir, 'foo')): subdir = pjoin(nbdir, 'foo') @@ -70,7 +86,7 @@ def cleanup_dir(): shutil.rmtree(subdir, ignore_errors=True) nb = new_notebook() - + nb.cells.append(new_markdown_cell(u'Created by test ³')) cc1 = new_code_cell(source=u'print(2*6)') cc1.outputs.append(new_output(output_type="stream", text=u'12')) @@ -79,12 +95,13 @@ def cleanup_dir(): execution_count=1, )) nb.cells.append(cc1) - + with io.open(pjoin(nbdir, 'foo', 'testnb.ipynb'), 'w', encoding='utf-8') as f: write(nb, f, version=4) self.nbconvert_api = NbconvertAPI(self.request) + self.nbconvert_config_api = NbconvertConfigAPI(self.request) @onlyif_cmds_exist('pandoc') def test_from_file(self): @@ -119,17 +136,32 @@ def test_from_file_zip(self): @onlyif_cmds_exist('pandoc') def test_from_post(self): nbmodel = self.request('GET', 'api/contents/foo/testnb.ipynb').json() - + r = self.nbconvert_api.from_post(format='html', nbmodel=nbmodel) self.assertEqual(r.status_code, 200) self.assertIn(u'text/html', r.headers['Content-Type']) self.assertIn(u'Created by test', r.text) self.assertIn(u'print', r.text) - + r = self.nbconvert_api.from_post(format='python', nbmodel=nbmodel) self.assertIn(u'text/x-python', r.headers['Content-Type']) self.assertIn(u'print(2*6)', r.text) + def test_config_from_post(self): + nbmodel = self.request('GET', 'api/contents/foo/testnb.ipynb').json() + html_config = json.dumps({"NbConvertApp":{"export_format": 'html'}}) + python_config = json.dumps({"NbConvertApp":{"export_format": 'python'}}) + + r = self.nbconvert_config_api.from_post(config=html_config, nbmodel=nbmodel) + self.assertEqual(r.status_code, 200) + self.assertIn(u'text/html', r.headers['Content-Type']) + self.assertIn(u'Created by test', r.text) + self.assertIn(u'print', r.text) + + r = self.nbconvert_config_api.from_post(config=python_config, nbmodel=nbmodel) + self.assertIn(u'text/x-python', r.headers['Content-Type']) + self.assertIn(u'print(2*6)', r.text) + @onlyif_cmds_exist('pandoc') def test_from_post_zip(self): nbmodel = self.request('GET', 'api/contents/foo/testnb.ipynb').json() @@ -137,3 +169,12 @@ def test_from_post_zip(self): r = self.nbconvert_api.from_post(format='latex', nbmodel=nbmodel) self.assertIn(u'application/zip', r.headers['Content-Type']) self.assertIn(u'.zip', r.headers['Content-Disposition']) + + @onlyif_cmds_exist('pandoc') + def test_config_from_post_zip(self): + nbmodel = self.request('GET', 'api/contents/foo/testnb.ipynb').json() + latex_config = json.dumps({'NbConvertApp': {'export_format': 'latex'}}) + + r = self.nbconvert_config_api.from_post(config=latex_config, nbmodel=nbmodel) + self.assertIn(u'application/zip', r.headers['Content-Type']) + self.assertIn(u'.zip', r.headers['Content-Disposition']) diff --git a/notebook/static/base/js/utils.js b/notebook/static/base/js/utils.js index 8f54a6384b..54570ce1a6 100644 --- a/notebook/static/base/js/utils.js +++ b/notebook/static/base/js/utils.js @@ -1185,7 +1185,8 @@ define([ js_idx_to_char_idx: js_idx_to_char_idx, char_idx_to_js_idx: char_idx_to_js_idx, _ansispan:_ansispan, - change_favicon: change_favicon + change_favicon: change_favicon, + _get_cookie:_get_cookie }; return utils; diff --git a/notebook/static/notebook/js/menubar.js b/notebook/static/notebook/js/menubar.js index 450ce310cd..a4faed0311 100644 --- a/notebook/static/notebook/js/menubar.js +++ b/notebook/static/notebook/js/menubar.js @@ -1,7 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -define([ +define('notebook/js/menubar',[ 'jquery', 'base/js/namespace', 'base/js/dialog', @@ -71,7 +71,7 @@ define([ } ); }; - + MenuBar.prototype.add_bundler_items = function() { var that = this; this.config.loaded.then(function() { @@ -82,7 +82,7 @@ define([ ids.forEach(function(bundler_id) { var bundler = bundlers[bundler_id]; var group = that.element.find('#'+bundler.group+'_menu') - + // Validate menu item metadata if(!group.length) { console.warn('unknown group', bundler.group, 'for bundler ID', bundler_id, '; skipping'); @@ -91,7 +91,7 @@ define([ console.warn('no label for bundler ID', bundler_id, '; skipping'); return; } - + // Insert menu item into correct group, click handler group.parent().removeClass('hidden'); var $li = $('
  • ') @@ -117,7 +117,7 @@ define([ w.location = url; } }; - + MenuBar.prototype._bundle = function(bundler_id) { // Read notebook path and base url here in case they change var notebook_path = utils.encode_uri_components(this.notebook.notebook_path); @@ -130,6 +130,162 @@ define([ this._new_window(url); }; + MenuBar.prototype._nbconvert_upload_conf = function(download) { + var body = $("
    "); + var notebook_path = utils.encode_uri_components( + this.notebook.notebook_path + ); + + var build_json_for_post = function(notebook, config, format) { + var json_to_pass = { + notebook_contents: { + content: notebook.toJSON(), + name: notebook.notebook_name, + last_modified: notebook.last_modified + }, + export_format: format, + config: config + }; + return json_to_pass; + }; + + var form = $("
    "); + var fileinput = $("") + .attr("type", "file") + .attr("tabindex", "1"); + + var fileformat = $("