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

Usability: Open browser, display url and shortcut to serving directory #136

Merged
merged 5 commits into from
May 14, 2019
Merged
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
18 changes: 18 additions & 0 deletions share/jupyter/voila/template/default/templates/browser-open.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{# This template is not served, but written as a file to open in the browser,
passing the token without putting it in a command-line argument. #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="refresh" content="1;url={{ open_url }}" />
<title>Opening voila</title>
</head>
<body>

<p>
This page should redirect you to voila. If it doesn't,
<a href="{{ open_url }}">click here to go to voila</a>.
</p>

</body>
</html>
2 changes: 1 addition & 1 deletion tests/app/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def voila_args(voila_notebook, voila_args_extra, voila_config_file_paths_arg):
@pytest.fixture
def voila_app(voila_args, voila_config):
voila_app = VoilaTest.instance()
voila_app.initialize(voila_args)
voila_app.initialize(voila_args + ['--no-browser'])
voila_config(voila_app)
voila_app.start()
yield voila_app
Expand Down
161 changes: 147 additions & 14 deletions voila/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,22 @@
#############################################################################

from zmq.eventloop import ioloop
import gettext
import logging
import threading
import tempfile
import os
import shutil
import signal
import tempfile
import logging
import gettext
import socket
import webbrowser

try:
from urllib.parse import urljoin
from urllib.request import pathname2url
except ImportError:
from urllib import pathname2url
from urlparse import urljoin

import jinja2

Expand Down Expand Up @@ -42,10 +52,19 @@
_kernel_id_regex = r"(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)"


# placeholder for i18
def _(x): return x


class Voila(Application):
name = 'voila'
version = __version__
examples = 'voila example.ipynb --port 8888'

flags = {
'no-browser': ({'Voila': {'open_browser': False}}, _('Don\'t open the notebook in a browser after startup.'))
}

description = Unicode(
"""voila [OPTIONS] NOTEBOOK_FILENAME

Expand Down Expand Up @@ -134,6 +153,78 @@ class Voila(Application):
)
)

ip = Unicode('localhost', config=True,
help=_("The IP address the notebook server will listen on."))

open_browser = Bool(True, config=True,
help="""Whether to open in a browser after starting.
The specific browser used is platform dependent and
determined by the python standard library `webbrowser`
module, unless it is overridden using the --browser
(NotebookApp.browser) configuration option.
""")

browser = Unicode(u'', config=True,
help="""Specify what command to use to invoke a web
browser when opening the notebook. If not specified, the
default browser will be determined by the `webbrowser`
standard library module, which allows setting of the
BROWSER environment variable to override it.
""")

webbrowser_open_new = Integer(2, config=True,
help=_("""Specify Where to open the notebook on startup. This is the
`new` argument passed to the standard library method `webbrowser.open`.
The behaviour is not guaranteed, but depends on browser support. Valid
values are:
- 2 opens a new tab,
- 1 opens a new window,
- 0 opens in an existing window.
See the `webbrowser.open` documentation for details.
"""))

custom_display_url = Unicode(u'', config=True,
help=_("""Override URL shown to users.
Replace actual URL, including protocol, address, port and base URL,
with the given value when displaying URL to the users. Do not change
the actual connection URL. If authentication token is enabled, the
token is added to the custom URL automatically.
This option is intended to be used when the URL to display to the user
cannot be determined reliably by the Jupyter notebook server (proxified
or containerized setups for example)."""))

@property
def display_url(self):
if self.custom_display_url:
url = self.custom_display_url
if not url.endswith('/'):
url += '/'
else:
if self.ip in ('', '0.0.0.0'):
ip = "%s" % socket.gethostname()
else:
ip = self.ip
url = self._url(ip)
# TODO: do we want to have the token?
# if self.token:
# # Don't log full token if it came from config
# token = self.token if self._token_generated else '...'
# url = (url_concat(url, {'token': token})
# + '\n or '
# + url_concat(self._url('127.0.0.1'), {'token': token}))
return url

@property
def connection_url(self):
ip = self.ip if self.ip else 'localhost'
return self._url(ip)

def _url(self, ip):
# TODO: https / certfile
# proto = 'https' if self.certfile else 'http'
proto = 'http'
return "%s://%s:%i%s" % (proto, ip, self.port, self.base_url)

config_file_paths = List(Unicode(), config=True, help='Paths to search for voila.(py|json)')

tornado_settings = Dict(
Expand Down Expand Up @@ -183,7 +274,19 @@ def initialize(self, argv=None):
self.log.debug("Searching path %s for config files", self.config_file_paths)
# to make config_file_paths settable via cmd line, we first need to parse it
super(Voila, self).initialize(argv)
self.notebook_path = self.notebook_path if self.notebook_path else self.extra_args[0] if len(self.extra_args) == 1 else None
if len(self.extra_args) == 1:
arg = self.extra_args[0]
# I am not sure why we need to check if self.notebook_path is set, can we get rid of this?
if not self.notebook_path:
if os.path.isdir(arg):
self.root_dir = arg
elif os.path.isfile(arg):
self.notebook_path = arg
else:
raise ValueError('argument is neither a file nor a directory: %r' % arg)
elif len(self.extra_args) != 0:
raise ValueError('provided more than 1 argument: %r' % self.extra_args)

# then we load the config
self.load_config_file('voila', path=self.config_file_paths)
# but that cli config has preference, so we overwrite with that
Expand Down Expand Up @@ -251,16 +354,17 @@ def start(self):
config_manager=self.config_manager
)

base_url = self.app.settings.get('base_url', '/')
# TODO: should this be configurable
self.base_url = self.app.settings.get('base_url', '/')
self.app.settings.update(self.tornado_settings)

handlers = []

handlers.extend([
(url_path_join(base_url, r'/api/kernels/%s' % _kernel_id_regex), KernelHandler),
(url_path_join(base_url, r'/api/kernels/%s/channels' % _kernel_id_regex), ZMQChannelsHandler),
(url_path_join(self.base_url, r'/api/kernels/%s' % _kernel_id_regex), KernelHandler),
(url_path_join(self.base_url, r'/api/kernels/%s/channels' % _kernel_id_regex), ZMQChannelsHandler),
(
url_path_join(base_url, r'/voila/static/(.*)'),
url_path_join(self.base_url, r'/voila/static/(.*)'),
MultiStaticFileHandler,
{
'paths': self.static_paths,
Expand All @@ -272,7 +376,7 @@ def start(self):
# this handler serves the nbextensions similar to the classical notebook
handlers.append(
(
url_path_join(base_url, r'/voila/nbextensions/(.*)'),
url_path_join(self.base_url, r'/voila/nbextensions/(.*)'),
FileFindHandler,
{
'path': self.nbextensions_path,
Expand All @@ -283,7 +387,7 @@ def start(self):

if self.notebook_path:
handlers.append((
url_path_join(base_url, r'/'),
url_path_join(self.base_url, r'/'),
VoilaHandler,
{
'notebook_path': os.path.relpath(self.notebook_path, self.root_dir),
Expand All @@ -296,9 +400,9 @@ def start(self):
else:
self.log.debug('serving directory: %r', self.root_dir)
handlers.extend([
(base_url, VoilaTreeHandler),
(url_path_join(base_url, r'/voila/tree' + path_regex), VoilaTreeHandler),
(url_path_join(base_url, r'/voila/render' + path_regex), VoilaHandler,
(self.base_url, VoilaTreeHandler),
(url_path_join(self.base_url, r'/voila/tree' + path_regex), VoilaTreeHandler),
(url_path_join(self.base_url, r'/voila/render' + path_regex), VoilaHandler,
{
'strip_sources': self.strip_sources,
'nbconvert_template_paths': self.nbconvert_template_paths,
Expand All @@ -307,11 +411,13 @@ def start(self):
])

self.app.add_handlers('.*$', handlers)
if self.open_browser:
self.launch_browser()
self.listen()

def listen(self):
self.app.listen(self.port)
self.log.info('Voila listening on port %s.' % self.port)
self.log.info('Voila is running at:\n%s' % self.display_url)

self.ioloop = tornado.ioloop.IOLoop.current()
try:
Expand All @@ -322,5 +428,32 @@ def listen(self):
shutil.rmtree(self.connection_dir)
self.kernel_manager.shutdown_all()

def launch_browser(self):
try:
browser = webbrowser.get(self.browser or None)
except webbrowser.Error as e:
self.log.warning(_('No web browser found: %s.') % e)
browser = None

if not browser:
return

uri = self.base_url
fd, open_file = tempfile.mkstemp(suffix='.html')
# Write a temporary file to open in the browser
with open(fd, 'w', encoding='utf-8') as fh:
# TODO: do we want to have the token?
# if self.token:
# url = url_concat(url, {'token': self.token})
url = url_path_join(self.connection_url, uri)

jinja2_env = self.app.settings['jinja2_env']
template = jinja2_env.get_template('browser-open.html')
fh.write(template.render(open_url=url))

def target():
return browser.open(urljoin('file:', pathname2url(open_file)), new=self.webbrowser_open_new)
threading.Thread(target=target).start()


main = Voila.launch_instance