From 01354ade942708a26e8395672a48d28d33cda255 2014-11-13 05:40:30
From: Min RK <benjaminrk@gmail.com>
Date: 2014-11-13 05:40:30
Subject: [PATCH] Merge pull request #6694 from takluyver/config-rest-api

Add REST API for retrieving, storing and updating config
---

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<section_name>\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(), {})
+