Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WIP] Download converted documents with uploadable configuration #2413

Closed
wants to merge 34 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
4588abc
create handler for nbconvert post
mpacer Mar 14, 2017
d197d4d
change post handler to expect a notebook in the passed in json
mpacer Mar 24, 2017
dc4bc76
Lots of changes to try to get the dialog to work
mpacer Apr 3, 2017
fa2deb9
actually downloads a file, unfortunately its not the converted file
mpacer Apr 4, 2017
823e1dc
intermediate work on getting the zip and tar files to behave appropri…
mpacer Apr 12, 2017
34a55b8
tar now actually works
mpacer Apr 13, 2017
7d3d688
use raw XMLHttpRequest and cookie from utils._get_cookie, test with tar
mpacer Apr 13, 2017
a850366
Clean up files
mpacer Apr 13, 2017
2b700fc
nicer names for things, get rid of logs
mpacer Apr 13, 2017
d22436b
Add back compression to zip
mpacer Apr 13, 2017
d946423
clean up whitespace and lines in server
mpacer Apr 18, 2017
d4503a4
loop through options
mpacer Apr 18, 2017
e0cff44
use valid object structure
mpacer Apr 18, 2017
9f01820
clean up js
mpacer Apr 18, 2017
ac9aa24
move custom download down the menu to be near Print preview
mpacer Apr 18, 2017
720fb4d
removing post argument from API
mpacer Apr 19, 2017
b555ac5
change handle to trigger in response to button press
mpacer Apr 20, 2017
ec218ff
use raw XMLHttpRequest and cookie from utils._get_cookie, test with tar
mpacer Apr 13, 2017
8e96ffb
match RFC5987 3.2 formatting for Content Disposition, prettify code
mpacer Oct 4, 2017
26d75de
cleanup logs, prints, and comments
mpacer Oct 4, 2017
704844b
remove buffer from use as var name (python keyword conflict)
mpacer Oct 6, 2017
cda47c1
return old get endpoint to its classic form
mpacer Oct 6, 2017
b9caeaf
create nbconvert-service endpoint, takes no arguments, json with cont…
mpacer Oct 6, 2017
6e62b25
create contents model style json object for post request
mpacer Oct 6, 2017
935a176
point to the correct api, gather the needed resources in that for pas…
mpacer Oct 6, 2017
4f1cce5
reformat js for onSave to be more idiomatic & better presented
mpacer Oct 6, 2017
2ab6ab9
Add SUPPORTED_METHODS class attributes, bring closer to previous code…
mpacer Oct 6, 2017
d39593b
remove ES6 style anonymous functions
mpacer Oct 7, 2017
b102783
don't assign values to that directly, in case that triggers a race co…
mpacer Oct 7, 2017
4a636b4
improve POST endpoint to be /nbconvert; clean up unused code
mpacer Oct 17, 2017
74137ef
Fixes for @rgbkrk's suggestions to make phantom tests pass
mpacer Feb 20, 2018
e237b21
improvements to the handler
mpacer Feb 21, 2018
e4342c5
add tests for NbconvertConfigHandler
mpacer Feb 21, 2018
a804423
Fixes in displaying export_formats
mpacer Feb 21, 2018
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 97 additions & 20 deletions notebook/nbconvert/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import io
import os
import json
import zipfile

from tornado import web, escape
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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.
Expand All @@ -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]
Expand All @@ -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:
Expand Down Expand Up @@ -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={
Expand Down Expand Up @@ -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),
Expand Down
59 changes: 50 additions & 9 deletions notebook/nbconvert/tests/test_nbconvert_handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,8 @@
from base64 import encodestring as encodebytes




class NbconvertAPI(object):
"""Wrapper for nbconvert API calls."""
"""Wrapper for API calls to /nbconvert/<format>."""
def __init__(self, request):
self.request = request

Expand All @@ -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'
Expand All @@ -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')

Expand All @@ -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'))
Expand All @@ -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):
Expand Down Expand Up @@ -119,21 +136,45 @@ 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()

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'])
3 changes: 2 additions & 1 deletion notebook/static/base/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading