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