diff --git a/IPython/html/base/handlers.py b/IPython/html/base/handlers.py
index 5cf6f24..906a44c 100644
--- a/IPython/html/base/handlers.py
+++ b/IPython/html/base/handlers.py
@@ -120,6 +120,10 @@ class IPythonHandler(AuthenticatedHandler):
return Application.instance().log
else:
return app_log
+
+ @property
+ def profile_dir(self):
+ return self.settings.get('profile_dir', '')
#---------------------------------------------------------------
# URLs
diff --git a/IPython/html/notebookapp.py b/IPython/html/notebookapp.py
index 0086ce7..80275c1 100644
--- a/IPython/html/notebookapp.py
+++ b/IPython/html/notebookapp.py
@@ -180,6 +180,7 @@ class NotebookWebApplication(web.Application):
config=ipython_app.config,
jinja2_env=env,
terminals_available=False, # Set later if terminals are available
+ profile_dir = ipython_app.profile_dir.location,
)
# allow custom overrides for the tornado web app.
@@ -198,6 +199,7 @@ class NotebookWebApplication(web.Application):
handlers.extend(load_handlers('notebook.handlers'))
handlers.extend(load_handlers('nbconvert.handlers'))
handlers.extend(load_handlers('kernelspecs.handlers'))
+ handlers.extend(load_handlers('services.config.handlers'))
handlers.extend(load_handlers('services.kernels.handlers'))
handlers.extend(load_handlers('services.contents.handlers'))
handlers.extend(load_handlers('services.clusters.handlers'))
diff --git a/IPython/html/services/config/__init__.py b/IPython/html/services/config/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/IPython/html/services/config/__init__.py
diff --git a/IPython/html/services/config/handlers.py b/IPython/html/services/config/handlers.py
new file mode 100644
index 0000000..411a0ab
--- /dev/null
+++ b/IPython/html/services/config/handlers.py
@@ -0,0 +1,102 @@
+"""Tornado handlers for frontend config storage."""
+
+# Copyright (c) IPython Development Team.
+# Distributed under the terms of the Modified BSD License.
+import json
+import os
+import io
+import errno
+from tornado import web
+
+from IPython.utils.py3compat import PY3
+from ...base.handlers import IPythonHandler, json_errors
+
+def recursive_update(target, new):
+ """Recursively update one dictionary using another.
+
+ None values will delete their keys.
+ """
+ for k, v in new.items():
+ if isinstance(v, dict):
+ if k not in target:
+ target[k] = {}
+ recursive_update(target[k], v)
+ if not target[k]:
+ # Prune empty subdicts
+ del target[k]
+
+ elif v is None:
+ target.pop(k, None)
+
+ else:
+ target[k] = v
+
+class ConfigHandler(IPythonHandler):
+ SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH')
+
+ @property
+ def config_dir(self):
+ return os.path.join(self.profile_dir, 'nbconfig')
+
+ def ensure_config_dir_exists(self):
+ try:
+ os.mkdir(self.config_dir, 0o755)
+ except OSError as e:
+ if e.errno != errno.EEXIST:
+ raise
+
+ def file_name(self, section_name):
+ return os.path.join(self.config_dir, section_name+'.json')
+
+ @web.authenticated
+ @json_errors
+ def get(self, section_name):
+ self.set_header("Content-Type", 'application/json')
+ filename = self.file_name(section_name)
+ if os.path.isfile(filename):
+ with io.open(filename, encoding='utf-8') as f:
+ self.finish(f.read())
+ else:
+ self.finish("{}")
+
+ @web.authenticated
+ @json_errors
+ def put(self, section_name):
+ self.get_json_body() # Will raise 400 if content is not valid JSON
+ filename = self.file_name(section_name)
+ self.ensure_config_dir_exists()
+ with open(filename, 'wb') as f:
+ f.write(self.request.body)
+ self.set_status(204)
+
+ @web.authenticated
+ @json_errors
+ def patch(self, section_name):
+ filename = self.file_name(section_name)
+ if os.path.isfile(filename):
+ with io.open(filename, encoding='utf-8') as f:
+ section = json.load(f)
+ else:
+ section = {}
+
+ update = self.get_json_body()
+ recursive_update(section, update)
+
+ self.ensure_config_dir_exists()
+ if PY3:
+ f = io.open(filename, 'w', encoding='utf-8')
+ else:
+ f = open(filename, 'wb')
+ with f:
+ json.dump(section, f)
+
+ self.finish(json.dumps(section))
+
+
+# URL to handler mappings
+
+section_name_regex = r"(?P\w+)"
+
+default_handlers = [
+ (r"/api/config/%s" % section_name_regex, ConfigHandler),
+]
diff --git a/IPython/html/services/config/tests/__init__.py b/IPython/html/services/config/tests/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/IPython/html/services/config/tests/__init__.py
diff --git a/IPython/html/services/config/tests/test_config_api.py b/IPython/html/services/config/tests/test_config_api.py
new file mode 100644
index 0000000..463fc4e
--- /dev/null
+++ b/IPython/html/services/config/tests/test_config_api.py
@@ -0,0 +1,68 @@
+# coding: utf-8
+"""Test the config webservice API."""
+
+import json
+
+import requests
+
+from IPython.html.utils import url_path_join
+from IPython.html.tests.launchnotebook import NotebookTestBase
+
+
+class ConfigAPI(object):
+ """Wrapper for notebook API calls."""
+ def __init__(self, base_url):
+ self.base_url = base_url
+
+ def _req(self, verb, section, body=None):
+ response = requests.request(verb,
+ url_path_join(self.base_url, 'api/config', section),
+ data=body,
+ )
+ response.raise_for_status()
+ return response
+
+ def get(self, section):
+ return self._req('GET', section)
+
+ def set(self, section, values):
+ return self._req('PUT', section, json.dumps(values))
+
+ def modify(self, section, values):
+ return self._req('PATCH', section, json.dumps(values))
+
+class APITest(NotebookTestBase):
+ """Test the config web service API"""
+ def setUp(self):
+ self.config_api = ConfigAPI(self.base_url())
+
+ def test_create_retrieve_config(self):
+ sample = {'foo': 'bar', 'baz': 73}
+ r = self.config_api.set('example', sample)
+ self.assertEqual(r.status_code, 204)
+
+ r = self.config_api.get('example')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.json(), sample)
+
+ def test_modify(self):
+ sample = {'foo': 'bar', 'baz': 73,
+ 'sub': {'a': 6, 'b': 7}, 'sub2': {'c': 8}}
+ self.config_api.set('example', sample)
+
+ r = self.config_api.modify('example', {'foo': None, # should delete foo
+ 'baz': 75,
+ 'wib': [1,2,3],
+ 'sub': {'a': 8, 'b': None, 'd': 9},
+ 'sub2': {'c': None} # should delete sub2
+ })
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.json(), {'baz': 75, 'wib': [1,2,3],
+ 'sub': {'a': 8, 'd': 9}})
+
+ def test_get_unknown(self):
+ # We should get an empty config dictionary instead of a 404
+ r = self.config_api.get('nonexistant')
+ self.assertEqual(r.status_code, 200)
+ self.assertEqual(r.json(), {})
+