diff --git a/.travis.yml b/.travis.yml index 62323635d..c1c9f6dda 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,8 +5,8 @@ os: env: matrix: - PYTHON_VERSION=2.7 - - PYTHON_VERSION=3.5 - PYTHON_VERSION=3.6 + - PYTHON_VERSION=3.7 before_install: - if [[ $TRAVIS_OS_NAME == linux ]]; then sudo apt-get update; fi - if [[ $TRAVIS_OS_NAME == linux ]]; then wget https://repo.continuum.io/miniconda/Miniconda-latest-Linux-x86_64.sh -O miniconda.sh; fi @@ -17,7 +17,9 @@ before_install: - conda config --set always_yes yes --set changeps1 no - conda update -q conda - conda info -a - - conda create -q -n test-environment python=$PYTHON_VERSION jupyter_server pytest pytest-cov nodejs + - conda create -q -n test-environment -c conda-forge python=$PYTHON_VERSION jupyter_server pytest==3.10.1 pytest-cov nodejs - source activate test-environment install: - - pip install . + - pip install ".[test]" +script: + - py.test tests/ diff --git a/setup.py b/setup.py index fae626eeb..8157c10f2 100644 --- a/setup.py +++ b/setup.py @@ -167,6 +167,9 @@ def run(self): 'jupyter_server>=0.0.3', 'nbconvert>=5.4,<6' ], + 'extras_require': { + 'test': ['mock', 'pytest<4', 'pytest-tornado5'] + }, 'author': 'QuantStack', 'author_email': 'info@quantstack.net', 'keywords': [ diff --git a/tests/notebooks/print.ipynb b/tests/notebooks/print.ipynb new file mode 100644 index 000000000..9317d4099 --- /dev/null +++ b/tests/notebooks/print.ipynb @@ -0,0 +1,34 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "print('Hi Voila!')" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "language": "python", + "name": "python" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.6.4" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/tests/voila_single_notebook_test.py b/tests/voila_single_notebook_test.py new file mode 100644 index 000000000..4aae238f9 --- /dev/null +++ b/tests/voila_single_notebook_test.py @@ -0,0 +1,65 @@ +import pytest +import tornado.web +import tornado.gen +import voila.app +import os +import re +import json +import logging +from traitlets.config import Application +try: + from unittest import mock +except: + import mock + +BASE_DIR = os.path.dirname(__file__) + +class VoilaTest(voila.app.Voila): + def listen(self): + pass # the ioloop is taken care of by the pytest-tornado framework + +@pytest.fixture +def voila_app(): + voila_app = VoilaTest.instance() + voila_app.initialize([os.path.join(BASE_DIR, 'notebooks/print.ipynb'), '--VoilaApp.log_level=DEBUG']) + voila_app.start() + return voila_app + +@pytest.fixture +def app(voila_app): + return voila_app.app + +@pytest.mark.gen_test +def test_hello_world(http_client, base_url): + response = yield http_client.fetch(base_url) + assert response.code == 200 + assert 'Hi Voila' in response.body.decode('utf-8') + +@pytest.mark.gen_test +def test_no_execute_allowed(voila_app, app, http_client, base_url): + assert voila_app.app is app + response = (yield http_client.fetch(base_url)).body.decode('utf-8') + pattern = r"""kernelId": ["']([0-9a-zA-Z-]+)["']""" + groups = re.findall(pattern, response) + kernel_id = groups[0] + print(kernel_id, base_url) + session_id = '445edd75-c6f5-45d2-8b58-5fe8f84a7123' + url = '{base_url}/api/kernels/{kernel_id}/channels?session_id={session_id}'.format( + kernel_id=kernel_id, base_url=base_url, session_id=session_id + ).replace('http://', 'ws://') + conn = yield tornado.websocket.websocket_connect(url) + + msg = { + "header": {"msg_id":"8573fb401ac848aab63c3bf0081e9b65","username":"username","session":"7a7d94334ea745f888d9c479fa738d63","msg_type":"execute_request","version":"5.2"}, + "metadata":{}, + "content":{"code":"print('la')","silent":False,"store_history":False,"user_expressions":{},"allow_stdin":False,"stop_on_error":False}, + "buffers":[], + "parent_header":{}, + "channel":"shell" + } + with mock.patch.object(voila_app.log, 'warning') as mock_warning: + yield conn.write_message(json.dumps(msg)) + # make sure the warning method is called + while not mock_warning.called: + yield tornado.gen.sleep(0.1) + mock_warning.assert_called_with('Received message of type "execute_request", which is not allowed. Ignoring.') diff --git a/voila/app.py b/voila/app.py index 88417b70e..c383a0497 100644 --- a/voila/app.py +++ b/voila/app.py @@ -33,6 +33,7 @@ from jupyter_server.services.config import ConfigManager from jupyter_server.base.handlers import FileFindHandler from jupyter_core.paths import jupyter_config_path, jupyter_path +from ipython_genutils.py3compat import getcwd from .paths import ROOT, STATIC_ROOT, collect_template_paths from .handler import VoilaHandler @@ -71,6 +72,9 @@ class Voila(Application): config=True, help='Will autoreload to server and the page when a template, js file or Python code changes' ) + root_dir = Unicode(config=True, + help="The directory to use for notebooks." + ) static_root = Unicode( STATIC_ROOT, config=True, @@ -126,6 +130,13 @@ def nbextensions_path(self): path.append(os.path.join(get_ipython_dir(), 'nbextensions')) return path + @default('root_dir') + def _default_root_dir(self): + if self.notebook_path: + return os.path.dirname(os.path.abspath(self.notebook_path)) + else: + return getcwd() + def parse_command_line(self, argv=None): super(Voila, self).parse_command_line(argv) self.notebook_path = self.extra_args[0] if len(self.extra_args) == 1 else None @@ -142,17 +153,19 @@ def parse_command_line(self, argv=None): self.log.debug('nbconvert template paths: %s', self.nbconvert_template_paths) self.log.debug('template paths: %s', self.template_paths) self.log.debug('static paths: %s', self.static_paths) + if self.notebook_path and not os.path.exists(self.notebook_path): + raise ValueError('Notebook not found: %s' % self.notebook_path) def start(self): - connection_dir = tempfile.mkdtemp( + self.connection_dir = tempfile.mkdtemp( prefix='voila_', dir=self.connection_dir_root ) - self.log.info('Storing connection files in %s.' % connection_dir) + self.log.info('Storing connection files in %s.' % self.connection_dir) self.log.info('Serving static files from %s.' % self.static_root) kernel_manager = MappingKernelManager( - connection_dir=connection_dir, + connection_dir=self.connection_dir, allowed_message_types=[ 'comm_msg', 'comm_info_request', @@ -165,14 +178,14 @@ def start(self): env = jinja2.Environment(loader=jinja2.FileSystemLoader(self.template_paths), extensions=['jinja2.ext.i18n'], **jenv_opt) nbui = gettext.translation('nbui', localedir=os.path.join(ROOT, 'i18n'), fallback=True) env.install_gettext_translations(nbui, newstyle=False) - contents_manager = LargeFileManager() # TODO: make this configurable like notebook + contents_manager = LargeFileManager(parent=self) # TODO: make this configurable like notebook # we create a config manager that load both the serverconfig and nbconfig (classical notebook) read_config_path = [os.path.join(p, 'serverconfig') for p in jupyter_config_path()] read_config_path += [os.path.join(p, 'nbconfig') for p in jupyter_config_path()] self.config_manager = ConfigManager(parent=self, read_config_path=read_config_path) - webapp = tornado.web.Application( + self.app = tornado.web.Application( kernel_manager=kernel_manager, allow_remote_access=True, autoreload=self.autoreload, @@ -184,7 +197,7 @@ def start(self): config_manager=self.config_manager ) - base_url = webapp.settings.get('base_url', '/') + base_url = self.app.settings.get('base_url', '/') handlers = [] @@ -218,7 +231,7 @@ def start(self): url_path_join(base_url, r'/'), VoilaHandler, { - 'notebook_path': self.notebook_path, + 'notebook_path': os.path.relpath(self.notebook_path, self.root_dir), 'strip_sources': self.strip_sources, 'nbconvert_template_paths': self.nbconvert_template_paths, 'config': self.config @@ -231,14 +244,16 @@ def start(self): (url_path_join(base_url, r'/voila/render' + path_regex), VoilaHandler, {'strip_sources': self.strip_sources}), ]) - webapp.add_handlers('.*$', handlers) + self.app.add_handlers('.*$', handlers) + self.listen() - webapp.listen(self.port) + def listen(self): + self.app.listen(self.port) self.log.info('Voila listening on port %s.' % self.port) try: tornado.ioloop.IOLoop.current().start() finally: - shutil.rmtree(connection_dir) + shutil.rmtree(self.connection_dir) main = Voila.launch_instance diff --git a/voila/handler.py b/voila/handler.py index d15285342..2dee8bb69 100644 --- a/voila/handler.py +++ b/voila/handler.py @@ -34,9 +34,12 @@ def get(self, path=None): # a template can use that to load classical notebook extensions, but does not have to notebook_config = self.config_manager.get('notebook') # except for the widget extension itself, since voila has its own - if "jupyter-js-widgets/extension" in notebook_config['load_extensions']: - notebook_config['load_extensions']["jupyter-js-widgets/extension"] = False - nbextensions = [name for name, enabled in notebook_config['load_extensions'].items() if enabled] + load_extensions = notebook_config.get('load_extensions', {}) + if "jupyter-js-widgets/extension" in load_extensions: + load_extensions["jupyter-js-widgets/extension"] = False + nbextensions = [name for name, enabled in load_extensions.items() if enabled] + else: + nbextensions = [] model = self.contents_manager.get(path=notebook_path) if 'content' in model: