Browse Source

Workspaces CLI (#5166)

* Formatting/documentation clean up.

* Create shell classes for workspaces CLI.

* Grab which workspace to export from CLI args.

* Switch jupyterlab_launcher to jupyterlab_server, PEP 8 compliance

* More PEP 8 appeasement.

* Initial implementation of export functionality.

* Clean up.

* Initial implementation of workspace import.

* Create the workspaes directory upon import if it does not already exist.

* Remove duplicate package.json in manifest.

* Wait longer for Chrome headless test on CI.

* Update browser test logic.

* Fix integrity test.

* Fix docs test.

* Fix cli script

* revert docs change

* try reordering

* revert change to script

* Try original file

* Fix printing of app directory in dev-mode

* Override config app dir

* Clean up handling of app_dir

* Print the page content on error

* cleanup chrome-test

* cleanup chrome-test

* more debug

* more debug

* more debug

* Fix handling of timeout

* cleanup

* Update staging index

* add handling of core dir
Afshin Darian 6 years ago
parent
commit
5d9b409777

+ 0 - 1
MANIFEST.in

@@ -2,7 +2,6 @@ include package.json
 include LICENSE
 include CONTRIBUTING.md
 include README.md
-include package.json
 include setupbase.py
 
 # Documentation

+ 1 - 1
clean.py

@@ -9,7 +9,7 @@ if os.name == 'nt':
     for (root, dnames, files) in os.walk(here):
         if 'node_modules' in dnames:
             subprocess.check_call(['rmdir', '/s', '/q', 'node_modules'],
-                cwd=root, shell=True)
+                                  cwd=root, shell=True)
             dnames.remove('node_modules')
 
 

+ 28 - 9
dev_mode/index.js

@@ -162,20 +162,39 @@ function main() {
 
   // Handle a browser test.
   var browserTest = PageConfig.getOption('browserTest');
+  var el = document.createElement('div');
+  el.id = 'browserTest';
+  document.body.appendChild(el);
+  el.textContent = '[]';
+
   if (browserTest.toLowerCase() === 'true') {
-    var caught_errors = [];
+    var errors = [];
+    var reported = false;
+    var timeout = 25000;
+
+    var report = function(errors) {
+      if (reported) {
+        return;
+      }
+      reported = true;
+      el.className = 'completed';
+    }
+
     window.onerror = function(msg, url, line, col, error) {
-      caught_errors.push(String(error));
+      errors.push(String(error));
+      el.textContent = JSON.stringify(errors)
     };
     console.error = function(message) {
-      caught_errors.push(String(message));
+      errors.push(String(message));
+      el.textContent = JSON.stringify(errors)
     };
-    lab.restored.then(function() {
-      var el = document.createElement('div');
-      el.id = 'browserResult';
-      el.textContent = JSON.stringify(caught_errors);
-      document.body.appendChild(el);
-    });
+
+    lab.restored
+      .then(function() { report(errors); })
+      .catch(function(reason) { report([`RestoreError: ${reason.message}`]); });
+
+    // Handle failures to restore after the timeout has elapsed.
+    window.setTimeout(function() { report(errors); }, timeout);
   }
 
 }

+ 2 - 2
docs/source/getting_started/changelog.rst

@@ -367,7 +367,7 @@ Changes for developers
    widget for a markdown file is a text editor, but the default rendered
    widget is the markdown viewer.
    (`#4692 <https://github.com/jupyterlab/jupyterlab/pull/4692>`__)
--  Add new workspace REST endpoints to ``jupyterlab_launcher`` and make
+-  Add new workspace REST endpoints to ``jupyterlab_server`` and make
    them available in ``@jupyterlab/services``.
    (`#4841 <https://github.com/jupyterlab/jupyterlab/pull/4841>`__)
 -  Documents created with a mimerenderer extension can now be accessed
@@ -481,7 +481,7 @@ Changes in the JupyterLab code infrastructure include:
    We will upgrade yarn, with NodeJS version 10 support, when a `bug in
    yarn <https://github.com/yarnpkg/yarn/issues/5935>`__ is fixed.
    (`#4804 <https://github.com/jupyterlab/jupyterlab/pull/4804>`__)
--  Various process utilities were moved to ``jupyterlab_launcher``.
+-  Various process utilities were moved to ``jupyterlab_server``.
    (`#4696 <https://github.com/jupyterlab/jupyterlab/pull/4696>`__)
 
 Other fixes

+ 6 - 6
examples/cell/main.py

@@ -24,9 +24,10 @@ class ExampleHandler(IPythonHandler):
 
     def get(self):
         """Get the main page for the application's interface."""
-        return self.write(self.render_template("index.html",
-            static=self.static_url, base_url=self.base_url,
-            token=self.settings['token']))
+        return self.write(self.render_template('index.html',
+                                               static=self.static_url,
+                                               base_url=self.base_url,
+                                               token=self.settings['token']))
 
     def get_template(self, name):
         loader = FileSystemLoader(HERE)
@@ -44,9 +45,8 @@ class ExampleApp(NotebookApp):
         default_handlers = [
             (r'/example/?', ExampleHandler),
             (r"/example/(.*)", FileFindHandler,
-                {'path': os.path.join(HERE, 'build')}),
-        ]
-        self.web_app.add_handlers(".*$", default_handlers)
+                {'path': os.path.join(HERE, 'build')})        ]
+        self.web_app.add_handlers('.*$', default_handlers)
 
 
 if __name__ == '__main__':

+ 6 - 6
examples/console/main.py

@@ -24,9 +24,10 @@ class ExampleHandler(IPythonHandler):
 
     def get(self):
         """Get the main page for the application's interface."""
-        return self.write(self.render_template("index.html",
-            static=self.static_url, base_url=self.base_url,
-            token=self.settings['token']))
+        return self.write(self.render_template('index.html',
+                                               static=self.static_url,
+                                               base_url=self.base_url,
+                                               token=self.settings['token']))
 
     def get_template(self, name):
         loader = FileSystemLoader(HERE)
@@ -44,9 +45,8 @@ class ExampleApp(NotebookApp):
         default_handlers = [
             (r'/example/?', ExampleHandler),
             (r"/example/(.*)", FileFindHandler,
-                {'path': os.path.join(HERE, 'build')}),
-        ]
-        self.web_app.add_handlers(".*$", default_handlers)
+                {'path': os.path.join(HERE, 'build')})        ]
+        self.web_app.add_handlers('.*$', default_handlers)
 
 
 if __name__ == '__main__':

+ 6 - 6
examples/filebrowser/main.py

@@ -24,9 +24,10 @@ class ExampleHandler(IPythonHandler):
 
     def get(self):
         """Get the main page for the application's interface."""
-        return self.write(self.render_template("index.html",
-            static=self.static_url, base_url=self.base_url,
-            token=self.settings['token']))
+        return self.write(self.render_template('index.html',
+                                               static=self.static_url,
+                                               base_url=self.base_url,
+                                               token=self.settings['token']))
 
     def get_template(self, name):
         loader = FileSystemLoader(HERE)
@@ -44,9 +45,8 @@ class ExampleApp(NotebookApp):
         default_handlers = [
             (r'/example/?', ExampleHandler),
             (r"/example/(.*)", FileFindHandler,
-                {'path': os.path.join(HERE, 'build')}),
-        ]
-        self.web_app.add_handlers(".*$", default_handlers)
+                {'path': os.path.join(HERE, 'build')})        ]
+        self.web_app.add_handlers('.*$', default_handlers)
 
 
 if __name__ == '__main__':

+ 6 - 5
examples/notebook/main.py

@@ -24,9 +24,10 @@ class ExampleHandler(IPythonHandler):
 
     def get(self):
         """Get the main page for the application's interface."""
-        return self.write(self.render_template("index.html",
-            static=self.static_url, base_url=self.base_url,
-            token=self.settings['token']))
+        return self.write(self.render_template('index.html',
+                                               static=self.static_url,
+                                               base_url=self.base_url,
+                                               token=self.settings['token']))
 
     def get_template(self, name):
         loader = FileSystemLoader(HERE)
@@ -44,9 +45,9 @@ class ExampleApp(NotebookApp):
         default_handlers = [
             (r'/example/?', ExampleHandler),
             (r"/example/(.*)", FileFindHandler,
-                {'path': os.path.join(HERE, 'build')}),
+                {'path': os.path.join(HERE, 'build')})
         ]
-        self.web_app.add_handlers(".*$", default_handlers)
+        self.web_app.add_handlers('.*$', default_handlers)
 
 
 if __name__ == '__main__':

+ 8 - 6
examples/terminal/main.py

@@ -24,10 +24,12 @@ class ExampleHandler(IPythonHandler):
 
     def get(self):
         """Get the main page for the application's interface."""
-        return self.write(self.render_template("index.html",
-            static=self.static_url, base_url=self.base_url,
-            token=self.settings['token'],
-            terminals_available=self.settings['terminals_available']))
+        available = self.settings['terminals_available']
+        return self.write(self.render_template('index.html',
+                                               static=self.static_url,
+                                               base_url=self.base_url,
+                                               token=self.settings['token'],
+                                               terminals_available=available))
 
     def get_template(self, name):
         loader = FileSystemLoader(HERE)
@@ -45,9 +47,9 @@ class ExampleApp(NotebookApp):
         default_handlers = [
             (r'/example/?', ExampleHandler),
             (r"/example/(.*)", FileFindHandler,
-                {'path': os.path.join(HERE, 'build')}),
+                {'path': os.path.join(HERE, 'build')})
         ]
-        self.web_app.add_handlers(".*$", default_handlers)
+        self.web_app.add_handlers('.*$', default_handlers)
 
 
 if __name__ == '__main__':

+ 4 - 5
jupyterlab/__init__.py

@@ -3,10 +3,9 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
-from ._version import __version__
-from .extension import load_jupyter_server_extension
+from ._version import __version__                     # noqa
+from .extension import load_jupyter_server_extension  # noqa
+
 
 def _jupyter_server_extension_paths():
-    return [{
-        "module": "jupyterlab"
-    }]
+    return [{'module': 'jupyterlab'}]

+ 27 - 11
jupyterlab/chrome-test.js

@@ -1,5 +1,5 @@
 const puppeteer = require('puppeteer');
-
+const inspect = require('util').inspect;
 const URL = process.argv[2];
 
 async function main() {
@@ -7,26 +7,42 @@ async function main() {
 
   const browser = await puppeteer.launch({ args: ['--no-sandbox'] });
   const page = await browser.newPage();
-  console.info('Navigating to page:', URL);
 
+  console.info('Navigating to page:', URL);
   await page.goto(URL);
-  console.info('Waiting for application to start...');
-  const res = await page.waitForSelector('#browserResult');
-  const textContent = await res.getProperty('textContent');
+  console.info('Waiting for page to load...');
+
+  const html = await page.content();
+  if (inspect(html).indexOf('jupyter-config-data') === -1) {
+    console.error('Error loading JupyterLab page:');
+    console.error(html);
+  }
+
+  const el = await page.waitForSelector('#browserTest', { timeout: 100000 });
+  console.log('Waiting for application to start...');
+  let testError = null;
+
+  try {
+    await page.waitForSelector('.completed');
+  } catch (e) {
+    testError = e;
+  }
+  const textContent = await el.getProperty('textContent');
   const errors = JSON.parse(await textContent.jsonValue());
-  await browser.close();
 
   for (let error of errors) {
-    console.error('got error', error);
-  }
-  if (errors.length !== 0) {
-    throw 'Got some errors';
+    console.error(`Parsed an error from text content: ${error.message}`, error);
   }
 
+  await browser.close();
+
+  if (testError) {
+    throw testError;
+  }
   console.info('Chrome test complete');
 }
 
-// stop process if we raise an error in the async fynction
+// Stop the process if an error is raised in the async function.
 process.on('unhandledRejection', up => {
   throw up;
 });

+ 16 - 16
jupyterlab/commands.py

@@ -103,8 +103,8 @@ def ensure_dev(logger=None):
     for theme in theme_packages:
         base_path = pjoin(parent, 'packages', theme)
         if not osp.exists(pjoin(base_path, 'static')):
-            yarn_proc = Process(['node', YARN_PATH, 'build:webpack'], cwd=base_path,
-                                logger=logger)
+            yarn_proc = Process(['node', YARN_PATH, 'build:webpack'],
+                                cwd=base_path, logger=logger)
             yarn_proc.wait()
 
     if not osp.exists(pjoin(parent, 'dev_mode', 'static')):
@@ -156,7 +156,7 @@ def watch_packages(logger=None):
     # Run typescript watch and wait for the string indicating it is done.
     ts_regex = r'.* Found 0 errors\. Watching for file changes\.'
     ts_proc = WatchHelper(['node', YARN_PATH, 'run', 'watch'],
-        cwd=ts_dir, logger=logger, startup_regex=ts_regex)
+                          cwd=ts_dir, logger=logger, startup_regex=ts_regex)
 
     # Run the metapackage file watcher.
     tsf_regex = 'Watching the metapackage files...'
@@ -184,8 +184,8 @@ def watch_dev(logger=None):
 
     # Run webpack watch and wait for compilation.
     wp_proc = WatchHelper(['node', YARN_PATH, 'run', 'watch'],
-        cwd=DEV_DIR, logger=logger,
-        startup_regex=WEBPACK_EXPECT)
+                          cwd=DEV_DIR, logger=logger,
+                          startup_regex=WEBPACK_EXPECT)
 
     return package_procs + [wp_proc]
 
@@ -260,14 +260,14 @@ def clean(app_dir=None):
 
 
 def build(app_dir=None, name=None, version=None, public_url=None,
-        logger=None, command='build:prod', kill_event=None,
-        clean_staging=False):
+          logger=None, command='build:prod', kill_event=None,
+          clean_staging=False):
     """Build the JupyterLab application.
     """
     _node_check()
     handler = _AppHandler(app_dir, logger, kill_event=kill_event)
     return handler.build(name=name, version=version, public_url=public_url,
-                  command=command, clean_staging=clean_staging)
+                         command=command, clean_staging=clean_staging)
 
 
 def get_app_info(app_dir=None, logger=None):
@@ -430,7 +430,7 @@ class _AppHandler(object):
         return True
 
     def build(self, name=None, version=None, public_url=None,
-            command='build:prod', clean_staging=False):
+              command='build:prod', clean_staging=False):
         """Build the application.
         """
         # Set up the build directory.
@@ -461,9 +461,9 @@ class _AppHandler(object):
         self._run(['node', YARN_PATH, 'install'], cwd=staging)
 
         proc = WatchHelper(['node', YARN_PATH, 'run', 'watch'],
-            cwd=pjoin(self.app_dir, 'staging'),
-            startup_regex=WEBPACK_EXPECT,
-            logger=self.logger)
+                           cwd=pjoin(self.app_dir, 'staging'),
+                           startup_regex=WEBPACK_EXPECT,
+                           logger=self.logger)
         return [proc]
 
     def list_extensions(self):
@@ -659,7 +659,6 @@ class _AppHandler(object):
         self.logger.info('Updating %s to version %s' % (name, latest))
         return self.install_extension('%s@%s' % (name, latest))
 
-
     def link_package(self, path):
         """Link a package at the given path.
 
@@ -781,7 +780,8 @@ class _AppHandler(object):
 
         errors = self._get_extension_compat()[extension]
         if errors:
-            self.logger.info('%s:%s (compatibility errors)' % (extension, RED_X))
+            self.logger.info('%s:%s (compatibility errors)' %
+                             (extension, RED_X))
             return False
 
         if check_installed_only:
@@ -836,7 +836,7 @@ class _AppHandler(object):
         return info
 
     def _populate_staging(self, name=None, version=None, public_url=None,
-            clean=False):
+                          clean=False):
         """Set up the assets in the staging directory.
         """
         app_dir = self.app_dir
@@ -908,7 +908,7 @@ class _AppHandler(object):
                 continue
             dname = pjoin(app_dir, 'extensions')
             self._update_local(key, source, dname, extensions[key],
-                'local_extensions')
+                               'local_extensions')
 
         # Update the list of local extensions if any were removed.
         if removed:

+ 59 - 37
jupyterlab/extension.py

@@ -25,12 +25,53 @@ Running the core application with no additional extensions or settings
 """
 
 
+def load_config(nbapp):
+    """Load the JupyterLab configuration and defaults for a given application.
+    """
+    from jupyterlab_server import LabConfig
+    from .commands import (
+        get_app_dir,
+        get_app_info,
+        get_workspaces_dir,
+        get_user_settings_dir,
+        pjoin
+    )
+
+    app_dir = getattr(nbapp, 'app_dir', get_app_dir())
+    info = get_app_info(app_dir)
+    public_url = info['publicUrl']
+    user_settings_dir = getattr(
+        nbapp, 'user_settings_dir', get_user_settings_dir()
+    )
+    workspaces_dir = getattr(nbapp, 'workspaces_dir', get_workspaces_dir())
+
+    config = LabConfig()
+    config.app_dir = app_dir
+    config.app_name = 'JupyterLab'
+    config.app_namespace = 'jupyterlab'
+    config.app_settings_dir = pjoin(app_dir, 'settings')
+    config.app_version = info['version']
+    config.cache_files = True
+    config.schemas_dir = pjoin(app_dir, 'schemas')
+    config.templates_dir = pjoin(app_dir, 'static')
+    config.themes_dir = pjoin(app_dir, 'themes')
+    config.user_settings_dir = user_settings_dir
+    config.workspaces_dir = workspaces_dir
+
+    if public_url:
+        config.public_url = public_url
+    else:
+        config.static_dir = pjoin(app_dir, 'static')
+
+    return config
+
+
 def load_jupyter_server_extension(nbapp):
     """Load the JupyterLab server extension.
     """
     # Delay imports to speed up jlpmapp
     from json import dumps
-    from jupyterlab_server import add_handlers, LabConfig
+    from jupyterlab_server import add_handlers
     from notebook.utils import url_path_join as ujoin, url_escape
     from notebook._version import version_info
     from tornado.ioloop import IOLoop
@@ -40,42 +81,38 @@ def load_jupyter_server_extension(nbapp):
         extensions_handler_path, ExtensionManager, ExtensionHandler
     )
     from .commands import (
-        get_app_dir, get_user_settings_dir, watch, ensure_dev, watch_dev,
-        pjoin, DEV_DIR, HERE, get_app_info, ensure_core, get_workspaces_dir
+        DEV_DIR, HERE, ensure_core, ensure_dev, watch, watch_dev, get_app_dir
     )
 
     web_app = nbapp.web_app
     logger = nbapp.log
-    config = LabConfig()
-    app_dir = getattr(nbapp, 'app_dir', get_app_dir())
-    user_settings_dir = getattr(
-        nbapp, 'user_settings_dir', get_user_settings_dir()
-    )
-    workspaces_dir = getattr(
-        nbapp, 'workspaces_dir', get_workspaces_dir()
-    )
 
-    # Print messages.
-    logger.info('JupyterLab extension loaded from %s' % HERE)
-    logger.info('JupyterLab application directory is %s' % app_dir)
-
-    config.app_name = 'JupyterLab'
-    config.app_namespace = 'jupyterlab'
-    config.page_url = '/lab'
-    config.cache_files = True
+    # Handle the app_dir
+    app_dir = getattr(nbapp, 'app_dir', get_app_dir())
 
     # Check for core mode.
     core_mode = False
     if getattr(nbapp, 'core_mode', False) or app_dir.startswith(HERE):
+        app_dir = HERE
         core_mode = True
         logger.info('Running JupyterLab in core mode')
 
     # Check for dev mode.
     dev_mode = False
     if getattr(nbapp, 'dev_mode', False) or app_dir.startswith(DEV_DIR):
+        app_dir = DEV_DIR
         dev_mode = True
         logger.info('Running JupyterLab in dev mode')
 
+    # Set the value on nbapp so it will get picked up in load_config
+    nbapp.app_dir = app_dir
+
+    config = load_config(nbapp)
+    config.app_name = 'JupyterLab'
+    config.app_namespace = 'jupyterlab'
+    config.page_url = '/lab'
+    config.cache_files = True
+
     # Check for watch.
     watch_mode = getattr(nbapp, 'watch', False)
 
@@ -107,32 +144,17 @@ def load_jupyter_server_extension(nbapp):
         nbapp.file_to_run = ''
 
     if core_mode:
-        app_dir = HERE
         logger.info(CORE_NOTE.strip())
         ensure_core(logger)
 
     elif dev_mode:
-        app_dir = DEV_DIR
         ensure_dev(logger)
         if not watch_mode:
             logger.info(DEV_NOTE)
 
-    config.app_settings_dir = pjoin(app_dir, 'settings')
-    config.schemas_dir = pjoin(app_dir, 'schemas')
-    config.themes_dir = pjoin(app_dir, 'themes')
-    config.workspaces_dir = workspaces_dir
-    info = get_app_info(app_dir)
-    config.app_version = info['version']
-    public_url = info['publicUrl']
-    if public_url:
-        config.public_url = public_url
-    else:
-        config.static_dir = pjoin(app_dir, 'static')
-
-    config.user_settings_dir = user_settings_dir
-
-    # The templates end up in the built static directory.
-    config.templates_dir = pjoin(app_dir, 'static')
+    # Print messages.
+    logger.info('JupyterLab extension loaded from %s' % HERE)
+    logger.info('JupyterLab application directory is %s' % app_dir)
 
     if watch_mode:
         logger.info('Starting JupyterLab watch mode...')

+ 134 - 9
jupyterlab/labapp.py

@@ -4,16 +4,21 @@
 # Copyright (c) Jupyter Development Team.
 # Distributed under the terms of the Modified BSD License.
 
-from notebook.notebookapp import NotebookApp, aliases, flags
-from jupyter_core.application import JupyterApp, base_aliases
+import json
+import os
+import sys
 
+from jupyter_core.application import JupyterApp, base_aliases
+from jupyterlab_server import slugify, WORKSPACE_EXTENSION
+from notebook.notebookapp import NotebookApp, aliases, flags
+from notebook.utils import url_path_join as ujoin
 from traitlets import Bool, Unicode
 
 from ._version import __version__
-from .extension import load_jupyter_server_extension
+from .extension import load_config, load_jupyter_server_extension
 from .commands import (
-    build, clean, get_app_dir, get_user_settings_dir, get_app_version,
-    get_workspaces_dir, get_app_dir
+    build, clean, get_app_dir, get_app_version, get_user_settings_dir,
+    get_workspaces_dir
 )
 
 
@@ -81,8 +86,7 @@ class LabCleanApp(JupyterApp):
     """
     aliases = clean_aliases
 
-    app_dir = Unicode('', config=True,
-        help="The app directory to clean")
+    app_dir = Unicode('', config=True, help='The app directory to clean')
 
     def start(self):
         clean(self.app_dir)
@@ -93,7 +97,8 @@ class LabPathApp(JupyterApp):
     description = """
     Print the configured paths for the JupyterLab application
 
-    The application path can be configured using the JUPYTERLAB_DIR environment variable.
+    The application path can be configured using the JUPYTERLAB_DIR
+        environment variable.
     The user settings path can be configured using the JUPYTERLAB_SETTINGS_DIR
         environment variable or it will fall back to
         `/lab/user-settings` in the default Jupyter configuration directory.
@@ -108,6 +113,124 @@ class LabPathApp(JupyterApp):
         print('Workspaces directory: %s' % get_workspaces_dir())
 
 
+class LabWorkspaceExportApp(JupyterApp):
+    version = version
+    description = """
+    Export a JupyterLab workspace
+    """
+
+    def start(self):
+        app = LabApp()
+        base_url = app.base_url
+        config = load_config(app)
+        directory = config.workspaces_dir
+        page_url = config.page_url
+
+        if len(self.extra_args) > 1:
+            print('Too many arguments were provided for workspace export.')
+            sys.exit(1)
+
+        raw = (page_url if not self.extra_args
+               else ujoin(config.workspaces_url, self.extra_args[0]))
+        slug = slugify(raw, base_url)
+        workspace_path = os.path.join(directory, slug + WORKSPACE_EXTENSION)
+
+        if os.path.exists(workspace_path):
+            with open(workspace_path) as fid:
+                try:  # to load the workspace file.
+                    print(fid.read())
+                except Exception as e:
+                    print(json.dumps(dict(data=dict(), metadata=dict(id=raw))))
+        else:
+            print(json.dumps(dict(data=dict(), metadata=dict(id=raw))))
+
+
+class LabWorkspaceImportApp(JupyterApp):
+    version = version
+    description = """
+    Import a JupyterLab workspace
+    """
+
+    def start(self):
+        app = LabApp()
+        base_url = app.base_url
+        config = load_config(app)
+        directory = config.workspaces_dir
+        page_url = config.page_url
+        workspaces_url = config.workspaces_url
+
+        if len(self.extra_args) != 1:
+            print('One argument is required for workspace import.')
+            sys.exit(1)
+
+        file_name = self.extra_args[0]
+        file_path = os.path.abspath(file_name)
+
+        if not os.path.exists(file_path):
+            print('%s does not exist.' % file_name)
+            sys.exit(1)
+
+        workspace = dict()
+        with open(file_path) as fid:
+            try:  # to load, parse, and validate the workspace file.
+                workspace = self._validate(fid, page_url, workspaces_url)
+            except Exception as e:
+                print('%s is not a valid workspace:\n%s' % (file_name, e))
+                sys.exit(1)
+
+        if not os.path.exists(directory):
+            try:
+                os.makedirs(directory)
+            except Exception as e:
+                print('Workspaces directory could not be created:\n%s' % e)
+                sys.exit(1)
+
+        slug = slugify(workspace['metadata']['id'], base_url)
+        workspace_path = os.path.join(directory, slug + WORKSPACE_EXTENSION)
+
+        # Write the workspace data to a file.
+        with open(workspace_path, 'w') as fid:
+            fid.write(json.dumps(workspace))
+
+        print('Saved workspace: %s' % workspace_path)
+
+    def _validate(self, data, page_url, workspaces_url):
+        workspace = json.load(data)
+
+        if 'data' not in workspace:
+            raise Exception('The `data` field is missing.')
+
+        if 'metadata' not in workspace:
+            raise Exception('The `metadata` field is missing.')
+
+        if 'id' not in workspace['metadata']:
+            raise Exception('The `id` field is missing in `metadata`.')
+
+        id = workspace['metadata']['id']
+        if id != page_url and not id.startswith(workspaces_url):
+            error = '%s does not match page_url or start with workspaces_url.'
+            raise Exception(error % id)
+
+        return workspace
+
+
+class LabWorkspaceApp(JupyterApp):
+    version = version
+    description = """
+    Import or export a JupyterLab workspace
+    """
+
+    subcommands = dict()
+    subcommands['export'] = (
+        LabWorkspaceExportApp,
+        LabWorkspaceExportApp.description.splitlines()[0]
+    )
+    subcommands['import'] = (
+        LabWorkspaceImportApp,
+        LabWorkspaceImportApp.description.splitlines()[0]
+    )
+
+
 lab_aliases = dict(aliases)
 lab_aliases['app-dir'] = 'LabApp.app_dir'
 
@@ -165,7 +288,9 @@ class LabApp(NotebookApp):
         build=(LabBuildApp, LabBuildApp.description.splitlines()[0]),
         clean=(LabCleanApp, LabCleanApp.description.splitlines()[0]),
         path=(LabPathApp, LabPathApp.description.splitlines()[0]),
-        paths=(LabPathApp, LabPathApp.description.splitlines()[0])
+        paths=(LabPathApp, LabPathApp.description.splitlines()[0]),
+        workspace=(LabWorkspaceApp, LabWorkspaceApp.description.splitlines()[0]),
+        workspaces=(LabWorkspaceApp, LabWorkspaceApp.description.splitlines()[0])
     )
 
     default_url = Unicode('/lab', config=True,

+ 2 - 2
jupyterlab/semver.py

@@ -1,8 +1,8 @@
 # -*- coding:utf-8 -*-
 # This file comes from https://github.com/podhmo/python-semver/blob/b42e9896e391e086b773fc621b23fa299d16b874/semver/__init__.py
-# 
+#
 # It is licensed under the following license:
-# 
+#
 # MIT License
 
 # Copyright (c) 2016 podhmo

+ 28 - 9
jupyterlab/staging/index.js

@@ -162,20 +162,39 @@ function main() {
 
   // Handle a browser test.
   var browserTest = PageConfig.getOption('browserTest');
+  var el = document.createElement('div');
+  el.id = 'browserTest';
+  document.body.appendChild(el);
+  el.textContent = '[]';
+
   if (browserTest.toLowerCase() === 'true') {
-    var caught_errors = [];
+    var errors = [];
+    var reported = false;
+    var timeout = 25000;
+
+    var report = function(errors) {
+      if (reported) {
+        return;
+      }
+      reported = true;
+      el.className = 'completed';
+    }
+
     window.onerror = function(msg, url, line, col, error) {
-      caught_errors.push(String(error));
+      errors.push(String(error));
+      el.textContent = JSON.stringify(errors)
     };
     console.error = function(message) {
-      caught_errors.push(String(message));
+      errors.push(String(message));
+      el.textContent = JSON.stringify(errors)
     };
-    lab.restored.then(function() {
-      var el = document.createElement('div');
-      el.id = 'browserResult';
-      el.textContent = JSON.stringify(caught_errors);
-      document.body.appendChild(el);
-    });
+
+    lab.restored
+      .then(function() { report(errors); })
+      .catch(function(reason) { report([`RestoreError: ${reason.message}`]); });
+
+    // Handle failures to restore after the timeout has elapsed.
+    window.setTimeout(function() { report(errors); }, timeout);
   }
 
 }

+ 1 - 3
packages/application/src/layoutrestorer.ts

@@ -605,9 +605,7 @@ namespace Private {
       type: 'split-area',
       orientation: area.orientation,
       sizes: area.sizes,
-      children: area.children.map(serializeArea).filter(area => !!area) as (
-        | ITabArea
-        | ISplitArea)[]
+      children: area.children.map(serializeArea).filter(area => !!area)
     };
   }
 

+ 3 - 0
packages/coreutils/src/statedb.ts

@@ -118,6 +118,9 @@ export class StateDB implements IStateDB {
     });
   }
 
+  /**
+   * A signal that emits the change type any time a value changes.
+   */
   get changed(): ISignal<this, StateDB.Change> {
     return this._changed;
   }

+ 6 - 6
packages/services/examples/browser-require/main.py

@@ -18,9 +18,10 @@ class ExampleHander(IPythonHandler):
 
     def get(self):
         """Get the main page for the application's interface."""
-        return self.write(self.render_template("index.html",
-            static=self.static_url, base_url=self.base_url,
-            token=self.settings['token']))
+        return self.write(self.render_template('index.html',
+                                               static=self.static_url,
+                                               base_url=self.base_url,
+                                               token=self.settings['token']))
 
     def get_template(self, name):
         return LOADER.load(self.settings['jinja2_env'], name)
@@ -34,10 +35,9 @@ class ExampleApp(NotebookApp):
     def start(self):
         handlers = [
             (r'/example/?', ExampleHander),
-            (r"/example/(.*)", FileFindHandler,
-                {'path': HERE}),
+            (r'/example/(.*)', FileFindHandler, {'path': HERE}),
         ]
-        self.web_app.add_handlers(".*$", handlers)
+        self.web_app.add_handlers('.*$', handlers)
         super(ExampleApp, self).start()
 
 

+ 7 - 6
packages/services/examples/browser/main.py

@@ -18,9 +18,10 @@ class ExampleHander(IPythonHandler):
 
     def get(self):
         """Get the main page for the application's interface."""
-        return self.write(self.render_template("index.html",
-            static=self.static_url, base_url=self.base_url,
-            token=self.settings['token']))
+        return self.write(self.render_template('index.html',
+                                               static=self.static_url,
+                                               base_url=self.base_url,
+                                               token=self.settings['token']))
 
     def get_template(self, name):
         return LOADER.load(self.settings['jinja2_env'], name)
@@ -32,12 +33,12 @@ class ExampleApp(NotebookApp):
     default_url = Unicode('/example')
 
     def start(self):
+        path = os.path.join(HERE, 'build')
         handlers = [
             (r'/example/?', ExampleHander),
-            (r"/example/(.*)", FileFindHandler,
-                {'path': os.path.join(HERE, 'build')}),
+            (r'/example/(.*)', FileFindHandler, {'path': path}),
         ]
-        self.web_app.add_handlers(".*$", handlers)
+        self.web_app.add_handlers('.*$', handlers)
         super(ExampleApp, self).start()
 
 

+ 2 - 1
packages/services/examples/node/main.py

@@ -21,7 +21,8 @@ class NodeApp(ProcessApp):
         with open('config.json', 'w') as fid:
             json.dump(config, fid)
 
-        cmd = [which('node'), 'index.js', '--jupyter-config-data=./config.json']
+        cmd = [which('node'),
+               'index.js', '--jupyter-config-data=./config.json']
         return cmd, dict(cwd=HERE)
 
 

+ 19 - 15
setup.py

@@ -76,7 +76,7 @@ def check_assets():
 
 
 cmdclass = create_cmdclass('jsdeps', data_files_spec=data_files_spec,
-    package_data_spec=package_data_spec)
+                           package_data_spec=package_data_spec)
 cmdclass['jsdeps'] = combine_commands(
     install_npm(build_cmd='build:prod', path=staging, source_dir=staging,
                 build_dir=pjoin(HERE, NAME, 'static'), npm=npm),
@@ -90,7 +90,11 @@ class JupyterlabDevelop(develop):
     def run(self):
         if not skip_npm:
             if not which('node'):
-                log.error('Please install nodejs and npm before continuing installation. nodejs may be installed using conda or directly from the nodejs website.')
+                error_message = """
+Please install nodejs and npm before continuing installation.
+nodejs may be installed using conda or directly from: https://nodejs.org/
+"""
+                log.error(error_message)
                 return
             run(npm, cwd=HERE)
         develop.run(self)
@@ -101,19 +105,19 @@ cmdclass['develop'] = JupyterlabDevelop
 
 
 setup_args = dict(
-    name             = NAME,
-    description      = DESCRIPTION,
-    long_description = LONG_DESCRIPTION,
-    version          = VERSION,
-    packages         = find_packages(),
-    cmdclass         = cmdclass,
-    author           = 'Jupyter Development Team',
-    author_email     = 'jupyter@googlegroups.com',
-    url              = 'http://jupyter.org',
-    license          = 'BSD',
-    platforms        = "Linux, Mac OS X, Windows",
-    keywords         = ['ipython', 'jupyter', 'Web'],
-    classifiers      = [
+    name=NAME,
+    description=DESCRIPTION,
+    long_description=LONG_DESCRIPTION,
+    version=VERSION,
+    packages=find_packages(),
+    cmdclass=cmdclass,
+    author='Jupyter Development Team',
+    author_email='jupyter@googlegroups.com',
+    url='http://jupyter.org',
+    license='BSD',
+    platforms='Linux, Mac OS X, Windows',
+    keywords=['ipython', 'jupyter', 'Web'],
+    classifiers=[
         'Development Status :: 4 - Beta',
         'Intended Audience :: Developers',
         'Intended Audience :: System Administrators',

+ 9 - 5
setupbase.py

@@ -23,7 +23,7 @@ import sys
 
 # BEFORE importing distutils, remove MANIFEST. distutils doesn't properly
 # update it when the contents of directories change.
-if os.path.exists('MANIFEST'): os.remove('MANIFEST')
+if os.path.exists('MANIFEST'): os.remove('MANIFEST') # noqa
 
 
 from distutils.cmd import Command
@@ -113,7 +113,7 @@ def find_packages(top=HERE):
         if os.path.exists(pjoin(d, '__init__.py')):
             packages.append(os.path.relpath(d, top).replace(os.path.sep, '.'))
         elif d != top:
-            # Do not look for packages in subfolders if current is not a package
+            # Don't look for packages in subfolders if current isn't a package.
             dirs[:] = []
     return packages
 
@@ -130,13 +130,14 @@ class bdist_egg_disabled(bdist_egg):
     Prevents setup.py install performing setuptools' default easy_install,
     which it should never ever do.
     """
+
     def run(self):
         sys.exit("Aborting implicit building of eggs. Use `pip install .` "
                  " to install from source.")
 
 
 def create_cmdclass(prerelease_cmd=None, package_data_spec=None,
-        data_files_spec=None):
+                    data_files_spec=None):
     """Create a command class with the given optional prerelease class.
 
     Parameters
@@ -313,7 +314,8 @@ def mtime(path):
     return os.stat(path).st_mtime
 
 
-def install_npm(path=None, build_dir=None, source_dir=None, build_cmd='build', force=False, npm=None):
+def install_npm(path=None, build_dir=None, source_dir=None, build_cmd='build',
+                force=False, npm=None):
     """Return a Command for managing an npm installation.
 
     Note: The command is skipped if the `--skip-npm` flag is used.
@@ -358,7 +360,9 @@ def install_npm(path=None, build_dir=None, source_dir=None, build_cmd='build', f
                           .format(npm_cmd[0]))
                 return
 
-            if force or is_stale(node_modules, pjoin(node_package, 'package.json')):
+            stale_package = is_stale(node_modules,
+                                     pjoin(node_package, 'package.json'))
+            if force or stale_package:
                 log.info('Installing build dependencies with npm.  This may '
                          'take a while...')
                 run(npm_cmd + ['install'], cwd=node_package)