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

Add pass-by-value API server #568

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions nbdime/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ def main_dispatch(args=None):
main = main_mergetool
elif cmd == 'server':
from nbdime.webapp.nbdimeserver import main
elif cmd == 'api':
from nbdime.webapp.nbapiserver import main
elif cmd == 'config-git':
# Call all git configs
from nbdime.vcs.git.diffdriver import main as diff_driver
Expand Down
39 changes: 26 additions & 13 deletions nbdime/args.py
Original file line number Diff line number Diff line change
Expand Up @@ -238,9 +238,8 @@ def add_git_config_subcommand(subparsers, enable, disable, subparser_help, enabl
)
return config


def add_web_args(parser, default_port=8888):
"""Adds a set of arguments common to all commands that show a web gui.
def add_server_args(parser, default_port=8888):
"""Adds a set of arguments common to all commands that run a server.
"""
port_help = (
"specify the port you want the server to run on. Default is %d%s." % (
Expand All @@ -251,6 +250,29 @@ def add_web_args(parser, default_port=8888):
default=default_port,
type=int,
help=port_help)
parser.add_argument(
'--ip',
default='127.0.0.1',
help="specify the interface to listen to for the web server. "
"NOTE: Setting this to anything other than 127.0.0.1/localhost "
"might comprimise the security of your computer. Use with care!")
parser.add_argument(
'--base-url',
default='/',
help="The base URL prefix under which to run the web app")
parser.add_argument(
'--num-processes',
default=1,
type=int,
help="The number of server processes to spawn (Unix only). "
"A value of <= 0 will use the number of cores available on this machine."
)

def add_web_args(parser, default_port=8888):
"""Adds a set of arguments common to all commands that show a web gui.
"""
add_server_args(parser, default_port)

parser.add_argument(
'-b', '--browser',
default=None,
Expand All @@ -263,12 +285,6 @@ def add_web_args(parser, default_port=8888):
help="prevent server shutting down on remote close request (when these"
" would normally be supported)."
)
parser.add_argument(
'--ip',
default='127.0.0.1',
help="specify the interface to listen to for the web server. "
"NOTE: Setting this to anything other than 127.0.0.1/localhost "
"might comprimise the security of your computer. Use with care!")
cwd = os.path.abspath(os.path.curdir)

parser.add_argument(
Expand All @@ -277,10 +293,6 @@ def add_web_args(parser, default_port=8888):
help="specify the working directory you want "
"the server to run from. Default is the "
"actual cwd at program start.")
parser.add_argument(
'--base-url',
default='/',
help="The base URL prefix under which to run the web app")
parser.add_argument(
'--show-unchanged',
dest='hide_unchanged',
Expand Down Expand Up @@ -528,6 +540,7 @@ def args_for_server(arguments):
workdirectory='cwd',
base_url='base_url',
hide_unchanged='hide_unchanged',
num_processes='num_processes'
)
ret = {kmap[k]: v for k, v in vars(arguments).items() if k in kmap}
if 'persist' in arguments:
Expand Down
140 changes: 140 additions & 0 deletions nbdime/webapp/nbapiserver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
#!/usr/bin/env python
# -*- coding:utf-8 -*-

import json
import sys

from jupyter_server.base.handlers import JupyterHandler, APIHandler
from jupyter_server.log import log_request
from tornado import web, escape

from .nbdimeserver import main_server

from ..args import ConfigBackedParser, add_server_args, args_for_server
from ..diffing.notebooks import diff_notebooks
from ..log import logger
from ..merging.notebooks import decide_notebook_merge
from ..nbmergeapp import _build_arg_parser as build_merge_parser


class NbApiHandler(JupyterHandler):
def initialize(self, **params):
self.params = params
self.body = None

def write_error(self, status_code, **kwargs):
exc_info = kwargs.get('exc_info', None)
if exc_info:
(etype, value, traceback) = exc_info
if etype == web.HTTPError:
self.set_header('Content-Type', 'text/plain')
return self.finish(str(value))
return super(NbApiHandler, self).write_error(status_code, **kwargs)

def get_notebook_argument(self, argname):
if not self.body:
self.body = json.loads(escape.to_unicode(self.request.body))

# Assuming a request of the form "{'argname':arg}"
arg = self.body[argname]

if not isinstance(arg, dict):
raise web.HTTPError(400, 'Expecting a notebook JSON object.')

try:
# Convert dictionary to a v4 notebook object with nbformat
from nbformat import versions, NBFormatError, reader, convert, validate
(major, minor) = reader.get_version(arg)
if major in versions:
nb = versions[major].to_notebook_json(arg, minor=minor)
else:
raise NBFormatError('Unsupported nbformat version %s' % major)
nb = convert(nb, 4)
except Exception as e:
self.log.exception(e)
raise web.HTTPError(422, 'Invalid notebook: %s' % argname)

return nb

class ApiDiffHandler(NbApiHandler, APIHandler):
def post(self):
base_nb = self.get_notebook_argument('base')
remote_nb = self.get_notebook_argument('remote')

try:
thediff = diff_notebooks(base_nb, remote_nb)
except Exception:
logger.exception('Error diffing documents:')
raise web.HTTPError(500, 'Error while attempting to diff documents')

self.finish({
'base': base_nb,
'diff': thediff,
})

class ApiMergeHandler(NbApiHandler, APIHandler):
def post(self):
base_nb = self.get_notebook_argument('base')
local_nb = self.get_notebook_argument('local')
remote_nb = self.get_notebook_argument('remote')
merge_args = self.settings.get('merge_args')
if merge_args is None:
merge_args = build_merge_parser().parse_args(['', '', ''])
merge_args.merge_strategy = 'mergetool'
self.settings['merge_args'] = merge_args

try:
decisions = decide_notebook_merge(base_nb, local_nb, remote_nb,
args=merge_args)
except Exception:
logger.exception('Error merging documents:')
raise web.HTTPError(500, 'Error while attempting to merge documents')

self.finish({
'base': base_nb,
'merge_decisions': decisions
})

def make_app(**params):
base_url = params.pop('base_url', '/')
handlers = [
(r'/api/diff', ApiDiffHandler, params),
(r'/api/merge', ApiMergeHandler, params)
]
if base_url != '/':
prefix = base_url.rstrip('/')
handlers = [
(prefix + path, cls, params)
for (path, cls, params) in handlers
]

settings = {
'log_function': log_request,
'base_url': base_url,
'local_hostnames': ['localhost', '127.0.0.1'],
}

app = web.Application(handlers, **settings)
app.exit_code = 0
return app

def _build_arg_parser():
"""
Creates an argument parser that lets the user specify a port
and displays a help message.
"""
description = 'Hostable, secure api interface for Nbdime.'
parser = ConfigBackedParser(description=description)
add_server_args(parser)
return parser


def main(args=None):
if args is None:
args = sys.argv[1:]
arguments = _build_arg_parser().parse_args(args)
return main_server(make_app, **args_for_server(arguments))


if __name__ == '__main__':
sys.exit(main())
20 changes: 10 additions & 10 deletions nbdime/webapp/nbdimeserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
from tornado import ioloop, web, escape, netutil, httpserver

from .. import __file__ as nbdime_root
from ..args import ConfigBackedParser, add_generic_args, add_web_args
from ..args import ConfigBackedParser, add_generic_args, add_web_args, args_for_server
from ..diffing.notebooks import diff_notebooks
from ..log import logger
from ..merging.notebooks import decide_notebook_merge
Expand Down Expand Up @@ -402,7 +402,8 @@ def make_app(**params):
app.exit_code = 0
return app

def init_app(on_port=None, closable=False, **params):

def init_app(make_app=make_app, on_port=None, closable=False, **params):
asyncio_patch()
_logger.debug('Using params: %s', params)
params.update({'closable': closable})
Expand All @@ -427,10 +428,13 @@ def init_app(on_port=None, closable=False, **params):
return app, server


def main_server(on_port=None, closable=False, **params):
app, server = init_app(on_port, closable, **params)
def main_server(make_app=make_app, on_port=None, closable=False, **params):
app, server = init_app(make_app, on_port, closable, **params)
is_win = sys.platform.startswith('win')
if not is_win:
server.start(params.pop("num_processes", 1))
io_loop = ioloop.IOLoop.current()
if sys.platform.startswith('win'):
if is_win:
# workaround for tornado on Windows:
# add no-op to wake every 5s
# to handle signals that may be ignored by the inner loop
Expand Down Expand Up @@ -458,11 +462,7 @@ def main(args=None):
if args is None:
args = sys.argv[1:]
arguments = _build_arg_parser().parse_args(args)
return main_server(port=arguments.port,
ip=arguments.ip,
cwd=arguments.workdirectory,
base_url=arguments.base_url,
)
return main_server(make_app, **args_for_server(arguments))


if __name__ == '__main__':
Expand Down