"""Tornado handlers for extension management.""" # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. import json import os import re from concurrent.futures import ThreadPoolExecutor from jupyter_server.base.handlers import APIHandler from jupyter_server.extension.handler import ExtensionHandlerMixin from tornado import gen, web from ..commands import ( get_app_info, install_extension, uninstall_extension, enable_extension, disable_extension, read_package, _AppHandler, get_latest_compatible_package_versions, AppOptions, _ensure_options ) def _make_extension_entry(name, description, url, enabled, core, latest_version, installed_version, status, pkg_type, installed=None, install=None): """Create an extension entry that can be sent to the client""" ret = dict( name=name, description=description, url=url, enabled=enabled, core=core, latest_version=latest_version, installed_version=installed_version, status=status, pkg_type=pkg_type ) if installed is not None: ret['installed'] = installed if install is not None: ret['install'] = install return ret def _ensure_compat_errors(info, app_options): """Ensure that the app info has compat_errors field""" handler = _AppHandler(app_options) info['compat_errors'] = handler._get_extension_compat() _message_map = { 'install': re.compile(r'(?P.*) needs to be included in build'), 'uninstall': re.compile(r'(?P.*) needs to be removed from build'), 'update': re.compile(r'(?P.*) changed from (?P.*) to (?P.*)'), } def _build_check_info(app_options): """Get info about packages scheduled for (un)install/update""" handler = _AppHandler(app_options) messages = handler.build_check(fast=True) # Decode the messages into a dict: status = {'install': [], 'uninstall': [], 'update': []} for msg in messages: for key, pattern in _message_map.items(): match = pattern.match(msg) if match: status[key].append(match.group('name')) return status class ExtensionManager(object): executor = ThreadPoolExecutor(max_workers=1) def __init__(self, app_options=None): app_options = _ensure_options(app_options) self.log = app_options.logger self.app_dir = app_options.app_dir self.core_config = app_options.core_config self.app_options = app_options self._outdated = None # To start fetching data on outdated extensions immediately, uncomment: # IOLoop.current().spawn_callback(self._get_outdated) @gen.coroutine def list_extensions(self): """Handle a request for all installed extensions""" app_options = self.app_options info = get_app_info(app_options=app_options) build_check_info = _build_check_info(app_options) _ensure_compat_errors(info, app_options) extensions = [] # TODO: the three for-loops below can be run concurrently for name, data in info['federated_extensions'].items(): status = 'ok' pkg_info = data #yield self._get_pkg_info(name, data) if info['compat_errors'].get(name, None): status = 'error' extensions.append(_make_extension_entry( name=name, description=pkg_info.get('description', ''), url=data.get('url', ''), enabled=(name not in info['disabled']), core=False, # Use wanted version to ensure we limit ourselves # within semver restrictions latest_version=data['version'], installed_version=data['version'], status=status, install=data.get('install', {}), pkg_type='prebuilt' )) for name, data in info['extensions'].items(): if name in info['shadowed_exts']: continue status = 'ok' pkg_info = yield self._get_pkg_info(name, data) if info['compat_errors'].get(name, None): status = 'error' else: for packages in build_check_info.values(): if name in packages: status = 'warning' extensions.append(_make_extension_entry( name=name, description=pkg_info.get('description', ''), url=data['url'], enabled=(name not in info['disabled']), core=False, # Use wanted version to ensure we limit ourselves # within semver restrictions latest_version=pkg_info['latest_version'], installed_version=data['version'], status=status, pkg_type='source' )) for name in build_check_info['uninstall']: data = yield self._get_scheduled_uninstall_info(name) if data is not None: extensions.append(_make_extension_entry( name=name, description=data.get('description', ''), url=data.get('homepage', ''), installed=False, enabled=False, core=False, latest_version=data['version'], installed_version=data['version'], status='warning', pkg_type='prebuilt' )) raise gen.Return(extensions) @gen.coroutine def install(self, extension): """Handle an install/update request""" try: install_extension(extension, app_options=self.app_options) except ValueError as e: raise gen.Return(dict(status='error', message=str(e))) raise gen.Return(dict(status='ok',)) @gen.coroutine def uninstall(self, extension): """Handle an uninstall request""" did_uninstall = uninstall_extension( extension, app_options=self.app_options) raise gen.Return(dict(status='ok' if did_uninstall else 'error',)) @gen.coroutine def enable(self, extension): """Handle an enable request""" enable_extension(extension, app_options=self.app_options) raise gen.Return(dict(status='ok',)) @gen.coroutine def disable(self, extension): """Handle a disable request""" disable_extension(extension, app_options=self.app_options) raise gen.Return(dict(status='ok',)) @gen.coroutine def _get_pkg_info(self, name, data): """Get information about a package""" info = read_package(data['path']) # Get latest version that is compatible with current lab: outdated = yield self._get_outdated() if outdated and name in outdated: info['latest_version'] = outdated[name] else: # Fallback to indicating that current is latest info['latest_version'] = info['version'] raise gen.Return(info) def _get_outdated(self): """Get a Future to information from `npm/yarn outdated`. This will cache the results. To refresh the cache, set self._outdated to None before calling. To bypass the cache, call self._load_outdated directly. """ # Ensure self._outdated is a Future for data on outdated extensions if self._outdated is None: self._outdated = self._load_outdated() # Return the Future return self._outdated def refresh_outdated(self): self._outdated = self._load_outdated() return self._outdated @gen.coroutine def _load_outdated(self): """Get the latest compatible version""" info = get_app_info(app_options=self.app_options) names = tuple(info['extensions'].keys()) data = yield self.executor.submit( get_latest_compatible_package_versions, names, app_options=self.app_options ) raise gen.Return(data) @gen.coroutine def _get_scheduled_uninstall_info(self, name): """Get information about a package that is scheduled for uninstallation""" target = os.path.join( self.app_dir, 'staging', 'node_modules', name, 'package.json') if os.path.exists(target): with open(target) as fid: raise gen.Return(json.load(fid)) else: raise gen.Return(None) class ExtensionHandler(ExtensionHandlerMixin, APIHandler): def initialize(self, manager=None, name=None): super(ExtensionHandler, self).initialize(name=name) self.manager = manager @web.authenticated @gen.coroutine def get(self): """GET query returns info on all installed extensions""" if self.get_argument('refresh', False) == '1': yield self.manager.refresh_outdated() extensions = yield self.manager.list_extensions() self.finish(json.dumps(extensions)) @web.authenticated @gen.coroutine def post(self): """POST query performs an action on a specific extension""" data = self.get_json_body() cmd = data['cmd'] name = data['extension_name'] if (cmd not in ('install', 'uninstall', 'enable', 'disable') or not name): raise web.HTTPError( 422, 'Could not process instruction %r with extension name %r' % ( cmd, name)) # TODO: Can we trust extension_name? Does it need sanitation? # It comes from an authenticated session, but its name is # ultimately from the NPM repository. ret_value = None try: if cmd == 'install': ret_value = yield self.manager.install(name) elif cmd == 'uninstall': ret_value = yield self.manager.uninstall(name) elif cmd == 'enable': ret_value = yield self.manager.enable(name) elif cmd == 'disable': ret_value = yield self.manager.disable(name) except gen.Return as e: ret_value = e.value except Exception as e: raise web.HTTPError(500, str(e)) if ret_value is None: self.set_status(200) else: self.finish(json.dumps(ret_value)) # The path for lab extensions handler. extensions_handler_path = r"/lab/api/extensions"