You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

590 lines
22 KiB

4 years ago
  1. #############################################################################
  2. # Copyright (c) 2018, Voila Contributors #
  3. # Copyright (c) 2018, QuantStack #
  4. # #
  5. # Distributed under the terms of the BSD 3-Clause License. #
  6. # #
  7. # The full license is in the file LICENSE, distributed with this software. #
  8. #############################################################################
  9. from zmq.eventloop import ioloop
  10. import gettext
  11. import io
  12. import json
  13. import logging
  14. import threading
  15. import tempfile
  16. import os
  17. import shutil
  18. import signal
  19. import socket
  20. import webbrowser
  21. import errno
  22. import random
  23. try:
  24. from urllib.parse import urljoin
  25. from urllib.request import pathname2url
  26. except ImportError:
  27. from urllib import pathname2url
  28. from urlparse import urljoin
  29. import jinja2
  30. import tornado.ioloop
  31. import tornado.web
  32. from traitlets.config.application import Application
  33. from traitlets.config.loader import Config
  34. from traitlets import Unicode, Integer, Bool, Dict, List, default
  35. from jupyter_server.services.kernels.kernelmanager import MappingKernelManager
  36. from jupyter_server.services.kernels.handlers import KernelHandler, ZMQChannelsHandler
  37. from jupyter_server.services.contents.largefilemanager import LargeFileManager
  38. from jupyter_server.base.handlers import FileFindHandler, path_regex
  39. from jupyter_server.config_manager import recursive_update
  40. from jupyter_server.utils import url_path_join
  41. from jupyter_server.services.config import ConfigManager
  42. from jupyter_client.kernelspec import KernelSpecManager
  43. from jupyter_core.paths import jupyter_config_path, jupyter_path
  44. from ipython_genutils.py3compat import getcwd
  45. from .paths import ROOT, STATIC_ROOT, collect_template_paths
  46. from .handler import VoilaHandler
  47. from .treehandler import VoilaTreeHandler
  48. from ._version import __version__
  49. from .static_file_handler import MultiStaticFileHandler, WhiteListFileHandler
  50. from .configuration import VoilaConfiguration
  51. from .execute import VoilaExecutePreprocessor
  52. from .exporter import VoilaExporter
  53. from .csspreprocessor import VoilaCSSPreprocessor
  54. ioloop.install()
  55. _kernel_id_regex = r"(?P<kernel_id>\w+-\w+-\w+-\w+-\w+)"
  56. def _(x):
  57. return x
  58. class Voila(Application):
  59. name = 'voila'
  60. version = __version__
  61. examples = 'voila example.ipynb --port 8888'
  62. flags = {
  63. 'debug': ({'Voila': {'log_level': logging.DEBUG}}, _("Set the log level to logging.DEBUG")),
  64. 'no-browser': ({'Voila': {'open_browser': False}}, _('Don\'t open the notebook in a browser after startup.'))
  65. }
  66. description = Unicode(
  67. """voila [OPTIONS] NOTEBOOK_FILENAME
  68. This launches a stand-alone server for read-only notebooks.
  69. """
  70. )
  71. option_description = Unicode(
  72. """
  73. notebook_path:
  74. File name of the Jupyter notebook to display.
  75. """
  76. )
  77. notebook_filename = Unicode()
  78. port = Integer(
  79. 8866,
  80. config=True,
  81. help=_(
  82. 'Port of the voila server. Default 8866.'
  83. )
  84. )
  85. autoreload = Bool(
  86. False,
  87. config=True,
  88. help=_(
  89. 'Will autoreload to server and the page when a template, js file or Python code changes'
  90. )
  91. )
  92. root_dir = Unicode(config=True, help=_('The directory to use for notebooks.'))
  93. static_root = Unicode(
  94. STATIC_ROOT,
  95. config=True,
  96. help=_(
  97. 'Directory holding static assets (HTML, JS and CSS files).'
  98. )
  99. )
  100. aliases = {
  101. 'port': 'Voila.port',
  102. 'static': 'Voila.static_root',
  103. 'strip_sources': 'VoilaConfiguration.strip_sources',
  104. 'autoreload': 'Voila.autoreload',
  105. 'template': 'VoilaConfiguration.template',
  106. 'theme': 'VoilaConfiguration.theme',
  107. 'base_url': 'Voila.base_url',
  108. 'server_url': 'Voila.server_url',
  109. 'enable_nbextensions': 'VoilaConfiguration.enable_nbextensions'
  110. }
  111. classes = [
  112. VoilaConfiguration,
  113. VoilaExecutePreprocessor,
  114. VoilaExporter,
  115. VoilaCSSPreprocessor
  116. ]
  117. connection_dir_root = Unicode(
  118. config=True,
  119. help=_(
  120. 'Location of temporry connection files. Defaults '
  121. 'to system `tempfile.gettempdir()` value.'
  122. )
  123. )
  124. connection_dir = Unicode()
  125. base_url = Unicode(
  126. '/',
  127. config=True,
  128. help=_(
  129. 'Path for voila API calls. If server_url is unset, this will be \
  130. used for both the base route of the server and the client. \
  131. If server_url is set, the server will server the routes prefixed \
  132. by server_url, while the client will prefix by base_url (this is \
  133. useful in reverse proxies).'
  134. )
  135. )
  136. server_url = Unicode(
  137. None,
  138. config=True,
  139. allow_none=True,
  140. help=_(
  141. 'Path to prefix to voila API handlers. Leave unset to default to base_url'
  142. )
  143. )
  144. notebook_path = Unicode(
  145. None,
  146. config=True,
  147. allow_none=True,
  148. help=_(
  149. 'path to notebook to serve with voila'
  150. )
  151. )
  152. nbconvert_template_paths = List(
  153. [],
  154. config=True,
  155. help=_(
  156. 'path to nbconvert templates'
  157. )
  158. )
  159. template_paths = List(
  160. [],
  161. allow_none=True,
  162. config=True,
  163. help=_(
  164. 'path to nbconvert templates'
  165. )
  166. )
  167. static_paths = List(
  168. [STATIC_ROOT],
  169. config=True,
  170. help=_(
  171. 'paths to static assets'
  172. )
  173. )
  174. port_retries = Integer(50, config=True,
  175. help=_("The number of additional ports to try if the specified port is not available.")
  176. )
  177. ip = Unicode('localhost', config=True,
  178. help=_("The IP address the notebook server will listen on."))
  179. open_browser = Bool(True, config=True,
  180. help=_("""Whether to open in a browser after starting.
  181. The specific browser used is platform dependent and
  182. determined by the python standard library `webbrowser`
  183. module, unless it is overridden using the --browser
  184. (NotebookApp.browser) configuration option.
  185. """))
  186. browser = Unicode(u'', config=True,
  187. help="""Specify what command to use to invoke a web
  188. browser when opening the notebook. If not specified, the
  189. default browser will be determined by the `webbrowser`
  190. standard library module, which allows setting of the
  191. BROWSER environment variable to override it.
  192. """)
  193. webbrowser_open_new = Integer(2, config=True,
  194. help=_("""Specify Where to open the notebook on startup. This is the
  195. `new` argument passed to the standard library method `webbrowser.open`.
  196. The behaviour is not guaranteed, but depends on browser support. Valid
  197. values are:
  198. - 2 opens a new tab,
  199. - 1 opens a new window,
  200. - 0 opens in an existing window.
  201. See the `webbrowser.open` documentation for details.
  202. """))
  203. custom_display_url = Unicode(u'', config=True,
  204. help=_("""Override URL shown to users.
  205. Replace actual URL, including protocol, address, port and base URL,
  206. with the given value when displaying URL to the users. Do not change
  207. the actual connection URL. If authentication token is enabled, the
  208. token is added to the custom URL automatically.
  209. This option is intended to be used when the URL to display to the user
  210. cannot be determined reliably by the Jupyter notebook server (proxified
  211. or containerized setups for example)."""))
  212. @property
  213. def display_url(self):
  214. if self.custom_display_url:
  215. url = self.custom_display_url
  216. if not url.endswith('/'):
  217. url += '/'
  218. else:
  219. if self.ip in ('', '0.0.0.0'):
  220. ip = "%s" % socket.gethostname()
  221. else:
  222. ip = self.ip
  223. url = self._url(ip)
  224. # TODO: do we want to have the token?
  225. # if self.token:
  226. # # Don't log full token if it came from config
  227. # token = self.token if self._token_generated else '...'
  228. # url = (url_concat(url, {'token': token})
  229. # + '\n or '
  230. # + url_concat(self._url('127.0.0.1'), {'token': token}))
  231. return url
  232. @property
  233. def connection_url(self):
  234. ip = self.ip if self.ip else 'localhost'
  235. return self._url(ip)
  236. def _url(self, ip):
  237. # TODO: https / certfile
  238. # proto = 'https' if self.certfile else 'http'
  239. proto = 'http'
  240. return "%s://%s:%i%s" % (proto, ip, self.port, self.base_url)
  241. config_file_paths = List(
  242. Unicode(),
  243. config=True,
  244. help=_(
  245. 'Paths to search for voila.(py|json)'
  246. )
  247. )
  248. tornado_settings = Dict(
  249. {},
  250. config=True,
  251. help=_(
  252. 'Extra settings to apply to tornado application, e.g. headers, ssl, etc'
  253. )
  254. )
  255. @default('config_file_paths')
  256. def _config_file_paths_default(self):
  257. return [os.getcwd()] + jupyter_config_path()
  258. @default('connection_dir_root')
  259. def _default_connection_dir(self):
  260. connection_dir = tempfile.gettempdir()
  261. self.log.info('Using %s to store connection files' % connection_dir)
  262. return connection_dir
  263. @default('log_level')
  264. def _default_log_level(self):
  265. return logging.INFO
  266. # similar to NotebookApp, except no extra path
  267. @property
  268. def nbextensions_path(self):
  269. """The path to look for Javascript notebook extensions"""
  270. path = jupyter_path('nbextensions')
  271. # FIXME: remove IPython nbextensions path after a migration period
  272. try:
  273. from IPython.paths import get_ipython_dir
  274. except ImportError:
  275. pass
  276. else:
  277. path.append(os.path.join(get_ipython_dir(), 'nbextensions'))
  278. return path
  279. @default('root_dir')
  280. def _default_root_dir(self):
  281. if self.notebook_path:
  282. return os.path.dirname(os.path.abspath(self.notebook_path))
  283. else:
  284. return getcwd()
  285. def initialize(self, argv=None):
  286. self.log.debug("Searching path %s for config files", self.config_file_paths)
  287. # to make config_file_paths settable via cmd line, we first need to parse it
  288. super(Voila, self).initialize(argv)
  289. if len(self.extra_args) == 1:
  290. arg = self.extra_args[0]
  291. # I am not sure why we need to check if self.notebook_path is set, can we get rid of this?
  292. if not self.notebook_path:
  293. if os.path.isdir(arg):
  294. self.root_dir = arg
  295. elif os.path.isfile(arg):
  296. self.notebook_path = arg
  297. else:
  298. raise ValueError('argument is neither a file nor a directory: %r' % arg)
  299. elif len(self.extra_args) != 0:
  300. raise ValueError('provided more than 1 argument: %r' % self.extra_args)
  301. # then we load the config
  302. self.load_config_file('voila', path=self.config_file_paths)
  303. # common configuration options between the server extension and the application
  304. self.voila_configuration = VoilaConfiguration(parent=self)
  305. self.setup_template_dirs()
  306. signal.signal(signal.SIGTERM, self._handle_signal_stop)
  307. def setup_template_dirs(self):
  308. if self.voila_configuration.template:
  309. collect_template_paths(
  310. self.nbconvert_template_paths,
  311. self.static_paths,
  312. self.template_paths,
  313. self.voila_configuration.template)
  314. # look for possible template-related config files
  315. template_conf_dir = [os.path.join(k, '..') for k in self.nbconvert_template_paths]
  316. conf_paths = [os.path.join(d, 'conf.json') for d in template_conf_dir]
  317. for p in conf_paths:
  318. # see if config file exists
  319. if os.path.exists(p):
  320. # load the template-related config
  321. with open(p) as json_file:
  322. conf = json.load(json_file)
  323. # update the overall config with it, preserving CLI config priority
  324. if 'traitlet_configuration' in conf:
  325. recursive_update(conf['traitlet_configuration'], self.voila_configuration.config.VoilaConfiguration)
  326. # pass merged config to overall voila config
  327. self.voila_configuration.config.VoilaConfiguration = Config(conf['traitlet_configuration'])
  328. self.log.debug('using template: %s', self.voila_configuration.template)
  329. self.log.debug('nbconvert template paths:\n\t%s', '\n\t'.join(self.nbconvert_template_paths))
  330. self.log.debug('template paths:\n\t%s', '\n\t'.join(self.template_paths))
  331. self.log.debug('static paths:\n\t%s', '\n\t'.join(self.static_paths))
  332. if self.notebook_path and not os.path.exists(self.notebook_path):
  333. raise ValueError('Notebook not found: %s' % self.notebook_path)
  334. def _handle_signal_stop(self, sig, frame):
  335. self.log.info('Handle signal %s.' % sig)
  336. self.ioloop.add_callback_from_signal(self.ioloop.stop)
  337. def start(self):
  338. self.connection_dir = tempfile.mkdtemp(
  339. prefix='voila_',
  340. dir=self.connection_dir_root
  341. )
  342. self.log.info('Storing connection files in %s.' % self.connection_dir)
  343. self.log.info('Serving static files from %s.' % self.static_root)
  344. self.kernel_spec_manager = KernelSpecManager(
  345. parent=self
  346. )
  347. self.kernel_manager = MappingKernelManager(
  348. parent=self,
  349. connection_dir=self.connection_dir,
  350. kernel_spec_manager=self.kernel_spec_manager,
  351. allowed_message_types=[
  352. 'comm_open',
  353. 'comm_close',
  354. 'comm_msg',
  355. 'comm_info_request',
  356. 'kernel_info_request',
  357. 'shutdown_request'
  358. ]
  359. )
  360. jenv_opt = {"autoescape": True} # we might want extra options via cmd line like notebook server
  361. env = jinja2.Environment(loader=jinja2.FileSystemLoader(self.template_paths), extensions=['jinja2.ext.i18n'], **jenv_opt)
  362. nbui = gettext.translation('nbui', localedir=os.path.join(ROOT, 'i18n'), fallback=True)
  363. env.install_gettext_translations(nbui, newstyle=False)
  364. self.contents_manager = LargeFileManager(parent=self)
  365. # we create a config manager that load both the serverconfig and nbconfig (classical notebook)
  366. read_config_path = [os.path.join(p, 'serverconfig') for p in jupyter_config_path()]
  367. read_config_path += [os.path.join(p, 'nbconfig') for p in jupyter_config_path()]
  368. self.config_manager = ConfigManager(parent=self, read_config_path=read_config_path)
  369. # default server_url to base_url
  370. self.server_url = self.server_url or self.base_url
  371. self.app = tornado.web.Application(
  372. base_url=self.base_url,
  373. server_url=self.server_url or self.base_url,
  374. kernel_manager=self.kernel_manager,
  375. kernel_spec_manager=self.kernel_spec_manager,
  376. allow_remote_access=True,
  377. autoreload=self.autoreload,
  378. voila_jinja2_env=env,
  379. jinja2_env=env,
  380. static_path='/',
  381. server_root_dir='/',
  382. contents_manager=self.contents_manager,
  383. config_manager=self.config_manager
  384. )
  385. self.app.settings.update(self.tornado_settings)
  386. handlers = []
  387. handlers.extend([
  388. (url_path_join(self.server_url, r'/api/kernels/%s' % _kernel_id_regex), KernelHandler),
  389. (url_path_join(self.server_url, r'/api/kernels/%s/channels' % _kernel_id_regex), ZMQChannelsHandler),
  390. (
  391. url_path_join(self.server_url, r'/voila/static/(.*)'),
  392. MultiStaticFileHandler,
  393. {
  394. 'paths': self.static_paths,
  395. 'default_filename': 'index.html'
  396. }
  397. )
  398. ])
  399. # Serving notebook extensions
  400. if self.voila_configuration.enable_nbextensions:
  401. handlers.append(
  402. (
  403. url_path_join(self.server_url, r'/voila/nbextensions/(.*)'),
  404. FileFindHandler,
  405. {
  406. 'path': self.nbextensions_path,
  407. 'no_cache_paths': ['/'], # don't cache anything in nbextensions
  408. },
  409. )
  410. )
  411. handlers.append(
  412. (
  413. url_path_join(self.server_url, r'/voila/files/(.*)'),
  414. WhiteListFileHandler,
  415. {
  416. 'whitelist': self.voila_configuration.file_whitelist,
  417. 'blacklist': self.voila_configuration.file_blacklist,
  418. 'path': self.root_dir,
  419. },
  420. )
  421. )
  422. tree_handler_conf = {
  423. 'voila_configuration': self.voila_configuration
  424. }
  425. if self.notebook_path:
  426. handlers.append((
  427. url_path_join(self.server_url, r'/(.*)'),
  428. VoilaHandler,
  429. {
  430. 'notebook_path': os.path.relpath(self.notebook_path, self.root_dir),
  431. 'nbconvert_template_paths': self.nbconvert_template_paths,
  432. 'config': self.config,
  433. 'voila_configuration': self.voila_configuration
  434. }
  435. ))
  436. else:
  437. self.log.debug('serving directory: %r', self.root_dir)
  438. handlers.extend([
  439. (self.server_url, VoilaTreeHandler, tree_handler_conf),
  440. (url_path_join(self.server_url, r'/voila/tree' + path_regex),
  441. VoilaTreeHandler, tree_handler_conf),
  442. (url_path_join(self.server_url, r'/voila/render/(.*)'),
  443. VoilaHandler,
  444. {
  445. 'nbconvert_template_paths': self.nbconvert_template_paths,
  446. 'config': self.config,
  447. 'voila_configuration': self.voila_configuration
  448. }),
  449. ])
  450. self.app.add_handlers('.*$', handlers)
  451. self.listen()
  452. def stop(self):
  453. shutil.rmtree(self.connection_dir)
  454. self.kernel_manager.shutdown_all()
  455. def random_ports(self, port, n):
  456. """Generate a list of n random ports near the given port.
  457. The first 5 ports will be sequential, and the remaining n-5 will be
  458. randomly selected in the range [port-2*n, port+2*n].
  459. """
  460. for i in range(min(5, n)):
  461. yield port + i
  462. for i in range(n-5):
  463. yield max(1, port + random.randint(-2*n, 2*n))
  464. def listen(self):
  465. for port in self.random_ports(self.port, self.port_retries+1):
  466. try:
  467. self.app.listen(port)
  468. self.port = port
  469. self.log.info('Voila is running at:\n%s' % self.display_url)
  470. except socket.error as e:
  471. if e.errno == errno.EADDRINUSE:
  472. self.log.info(_('The port %i is already in use, trying another port.') % port)
  473. continue
  474. elif e.errno in (errno.EACCES, getattr(errno, 'WSAEACCES', errno.EACCES)):
  475. self.log.warning(_("Permission to listen on port %i denied") % port)
  476. continue
  477. else:
  478. raise
  479. else:
  480. self.port = port
  481. success = True
  482. break
  483. if not success:
  484. self.log.critical(_('ERROR: the voila server could not be started because '
  485. 'no available port could be found.'))
  486. self.exit(1)
  487. if self.open_browser:
  488. self.launch_browser()
  489. self.ioloop = tornado.ioloop.IOLoop.current()
  490. try:
  491. self.ioloop.start()
  492. except KeyboardInterrupt:
  493. self.log.info('Stopping...')
  494. finally:
  495. self.stop()
  496. def launch_browser(self):
  497. try:
  498. browser = webbrowser.get(self.browser or None)
  499. except webbrowser.Error as e:
  500. self.log.warning(_('No web browser found: %s.') % e)
  501. browser = None
  502. if not browser:
  503. return
  504. uri = self.base_url
  505. fd, open_file = tempfile.mkstemp(suffix='.html')
  506. # Write a temporary file to open in the browser
  507. with io.open(fd, 'w', encoding='utf-8') as fh:
  508. # TODO: do we want to have the token?
  509. # if self.token:
  510. # url = url_concat(url, {'token': self.token})
  511. url = url_path_join(self.connection_url, uri)
  512. jinja2_env = self.app.settings['jinja2_env']
  513. template = jinja2_env.get_template('browser-open.html')
  514. fh.write(template.render(open_url=url, base_url=url))
  515. def target():
  516. return browser.open(urljoin('file:', pathname2url(open_file)), new=self.webbrowser_open_new)
  517. threading.Thread(target=target).start()
  518. main = Voila.launch_instance