diff options
author | Marcel Hellkamp <marc@gsites.de> | 2016-09-02 00:00:51 +0200 |
---|---|---|
committer | Marcel Hellkamp <marc@gsites.de> | 2016-09-25 17:28:58 +0200 |
commit | 21aeec0636748d8bf577e29da72cc3c0f0341555 (patch) | |
tree | d2413620cb6afadbb206750231a4391e86cc068f | |
parent | c6fb8a46db3fe1200d6aff4c958cb1751c66dcd8 (diff) | |
download | bottle-config-overlay.tar.gz |
Rewrite 'overlay' feature for ConfigDict.config-overlay
-rwxr-xr-x | bottle.py | 187 | ||||
-rw-r--r-- | test/test_config.py | 100 | ||||
-rw-r--r-- | test/test_mount.py | 2 | ||||
-rw-r--r-- | test/test_route.py | 9 |
4 files changed, 203 insertions, 95 deletions
@@ -530,7 +530,8 @@ class Route(object): #: Additional keyword arguments passed to the :meth:`Bottle.route` #: decorator are stored in this dictionary. Used for route-specific #: plugin configuration and meta-data. - self.config = ConfigDict().load_dict(config) + self.config = app.config._make_overlay() + self.config.load_dict(config) @cached_property def call(self): @@ -599,9 +600,10 @@ class Route(object): def get_config(self, key, default=None): """ Lookup a config field and return its value, first checking the route.config, then route.app.config.""" - for conf in (self.config, self.app.config): - if key in conf: return conf[key] - return default + depr(0, 13, "Route.get_config() is deprectated.", + "The Route.config property already includes values from the" + " application config for missing keys. Access it directly.") + return self.config.get(key, default) def __repr__(self): cb = self.get_undecorated_callback() @@ -621,14 +623,32 @@ class Bottle(object): let debugging middleware handle exceptions. """ - def __init__(self, catchall=True, autojson=True): + @lazy_attribute + def _global_config(cls): + cfg = ConfigDict() + cfg.meta_set('catchall', 'validate', bool) + return cfg + + def __init__(self, **kwargs): #: A :class:`ConfigDict` for app specific configuration. - self.config = ConfigDict() - self.config._add_change_listener(functools.partial(self.trigger_hook, 'config')) - self.config.meta_set('autojson', 'validate', bool) - self.config.meta_set('catchall', 'validate', bool) - self.config['catchall'] = catchall - self.config['autojson'] = autojson + self.config = self._global_config._make_overlay() + self.config._add_change_listener( + functools.partial(self.trigger_hook, 'config')) + + self.config.update({ + "catchall": True + }) + + if kwargs.get('catchall') is False: + depr(0,13, "Bottle(catchall) keyword argument.", + "The 'catchall' setting is now part of the app " + "configuration. Fix: `app.config['catchall'] = False`") + self.config['catchall'] = False + if kwargs.get('autojson') is False: + depr(0, 13, "Bottle(autojson) keyword argument.", + "The 'autojson' setting is now part of the app " + "configuration. Fix: `app.config['json.enable'] = False`") + self.config['json.disable'] = True self._mounts = [] @@ -641,8 +661,7 @@ class Bottle(object): # Core plugins self.plugins = [] # List of installed plugins. - if self.config['autojson']: - self.install(JSONPlugin()) + self.install(JSONPlugin()) self.install(TemplatePlugin()) #: If true, most exceptions are caught and returned as :exc:`HTTPError` @@ -1901,9 +1920,21 @@ class JSONPlugin(object): def __init__(self, json_dumps=json_dumps): self.json_dumps = json_dumps - def apply(self, callback, _): + def setup(self, app): + app.config._define('json.enable', default=True, validate=bool, + help="Enable or disable automatic dict->json filter.") + app.config._define('json.ascii', default=False, validate=bool, + help="Use only 7-bit ASCII characters in output.") + app.config._define('json.indent', default=True, validate=bool, + help="Add whitespace to make json more readable.") + app.config._define('json.dump_func', default=None, + help="If defined, use this function to transform" + " dict into json. The other options no longer" + " apply.") + + def apply(self, callback, route): dumps = self.json_dumps - if not dumps: return callback + if not self.json_dumps: return callback def wrapper(*a, **ka): try: @@ -2232,18 +2263,27 @@ class WSGIHeaderDict(DictMixin): def __contains__(self, key): return self._ekey(key) in self.environ +_UNSET = object() class ConfigDict(dict): """ A dict-like configuration storage with additional support for - namespaces, validators, meta-data, on_change listeners and more. + namespaces, validators, meta-data, overlays and more. + + This dict-like class is heavily optimized for read access. All read-only + methods as well as item access should be as fast as the built-in dict. """ - __slots__ = ('_meta', '_change_listener', '_fallbacks') + __slots__ = ('_meta', '_change_listener', '_overlays', '_virtual_keys', '_source') def __init__(self): self._meta = {} self._change_listener = [] - self._fallbacks = [] + #: Configs that overlay this one and need to be kept in sync. + self._overlays = [] + #: Config that is the source for this overlay. + self._source = None + #: Keys of values copied from the source (values we do not own) + self._virtual_keys = set() def load_module(self, path, squash=True): """Load values from a Python module. @@ -2360,23 +2400,59 @@ class ConfigDict(dict): if not isinstance(key, basestring): raise TypeError('Key has type %r (not a string)' % type(key)) + self._virtual_keys.discard(key) + value = self.meta_get(key, 'filter', lambda x: x)(value) if key in self and self[key] is value: return + self._on_change(key, value) dict.__setitem__(self, key, value) + for overlay in self._overlays: + overlay._set_virtual(key, value) + def __delitem__(self, key): - self._on_change(key, None) - dict.__delitem__(self, key) + if key not in self: + raise KeyError(key) + if key in self._virtual_keys: + raise KeyError("Virtual keys cannot be deleted: %s" % key) + + if self._source and key in self._source: + # Not virtual, but present in source -> Restore virtual value + dict.__delitem__(self, key) + self._set_virtual(key, self._source[key]) + else: # not virtual, not present in source. This is OUR value + self._on_change(key, None) + dict.__delitem__(self, key) + for overlay in self._overlays: + overlay._delete_virtual(key) + + def _set_virtual(self, key, value): + """ Recursively set or update virtual keys. Do nothing if non-virtual + value is present. """ + if key in self and key not in self._virtual_keys: + return # Do nothing for non-virtual keys. + + self._virtual_keys.add(key) + if key in self and self[key] is not value: + self._on_change(key, value) + dict.__setitem__(self, key, value) + for overlay in self._overlays: + overlay._set_virtual(key, value) + + def _delete_virtual(self, key): + """ Recursively delete virtual entry. Do nothing if key is not virtual. + """ + if key not in self._virtual_keys: + return # Do nothing for non-virtual keys. - def __missing__(self, key): - for fallback in self._fallbacks: - if key in fallback: - value = self[key] = fallback[key] - self.meta_set(key, 'fallback', fallback) - return value - raise KeyError(key) + if key in self: + self._on_change(key, None) + dict.__delitem__(self, key) + self._virtual_keys.discard(key) + for overlay in self._overlays: + overlay._delete_virtual(key) def _on_change(self, key, value): for cb in self._change_listener: @@ -2387,20 +2463,6 @@ class ConfigDict(dict): self._change_listener.append(func) return func - def _set_fallback(self, fallback): - self._fallbacks.append(fallback) - - @fallback._add_change_listener - def fallback_update(conf, key, value): - if self.meta_get(key, 'fallback') is conf: - self.meta_set(key, 'fallback', None) - dict.__delitem__(self, key) - - @self._add_change_listener - def self_update(conf, key, value): - if conf.meta_get(key, 'fallback'): - conf.meta_set(key, 'fallback', None) - def meta_get(self, key, metafield, default=None): """ Return the value of a meta field for a key. """ return self._meta.get(key, {}).get(metafield, default) @@ -2413,6 +2475,49 @@ class ConfigDict(dict): """ Return an iterable of meta field names defined for a key. """ return self._meta.get(key, {}).keys() + def _define(self, key, default=_UNSET, help=_UNSET, validate=_UNSET): + """ (Unstable) Shortcut for plugins to define own config parameters. """ + if default is not _UNSET: + self.setdefault(key, default) + if help is not _UNSET: + self.meta_set(key, 'help', help) + if validate is not _UNSET: + self.meta_set(key, 'validate', validate) + + def _make_overlay(self): + """ (Unstable) Create a new overlay that acts like a chained map: Values + missing in the overlay are copied from the source map. Both maps + share the same meta entries. + + Entries that were copied from the source are called 'virtual'. You + can not delete virtual keys, but overwrite them, which turns them + into non-virtual entries. Setting keys on an overlay never affects + its source, but may affect any number of child overlays. + + Other than collections.ChainMap or most other implementations, this + approach does not resolve missing keys on demand, but instead + actively copies all values from the source to the overlay and keeps + track of virtual and non-virtual keys internally. This removes any + lookup-overhead. Read-access is as fast as a build-in dict for both + virtual and non-virtual keys. + + Changes are propagated recursively and depth-first. A failing + on-change handler in an overlay stops the propagation of virtual + values and may result in an partly updated tree. Take extra care + here and make sure that on-change handlers never fail. + + Used by Route.config + """ + overlay = ConfigDict() + overlay._meta = self._meta + overlay._source = self + self._overlays.append(overlay) + for key in self: + overlay._set_virtual(key, self[key]) + return overlay + + + class AppStack(list): """ A stack-like list. Calling it returns the head of the stack. """ diff --git a/test/test_config.py b/test/test_config.py index 25525a6..6c38801 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -5,6 +5,8 @@ import unittest import functools +import itertools + from bottle import ConfigDict @@ -85,54 +87,56 @@ class TestConfDict(unittest.TestCase): c.load_module('example_settings', False) self.assertEqual(c['A']['B']['C'], 3) - def test_fallback(self): - fallback = ConfigDict() - fallback['key'] = 'fallback' - primary = ConfigDict() - primary._set_fallback(fallback) - - # Check copy of existing values from fallback to primary - self.assertEqual(primary['key'], 'fallback') - - # Check value change in fallback - fallback['key'] = 'fallback2' - self.assertEqual(fallback['key'], 'fallback2') - self.assertEqual(primary['key'], 'fallback2') - - # Check value change in primary - primary['key'] = 'primary' - self.assertEqual(fallback['key'], 'fallback2') - self.assertEqual(primary['key'], 'primary') - - # Check delete of mirrored value in primary - del primary['key'] - self.assertEqual(fallback['key'], 'fallback2') - self.assertEqual(primary['key'], 'fallback2') - - # Check delete on mirrored key in fallback - del fallback['key'] - self.assertTrue('key' not in primary) - self.assertTrue('key' not in fallback) - - # Check new key in fallback - fallback['key2'] = 'fallback' - self.assertEqual(fallback['key2'], 'fallback') - self.assertEqual(primary['key2'], 'fallback') - - # Check new key in primary - primary['key3'] = 'primary' - self.assertEqual(primary['key3'], 'primary') - self.assertTrue('key3' not in fallback) - - # Check delete of primary-only key - del primary['key3'] - self.assertTrue('key3' not in primary) - self.assertTrue('key3' not in fallback) - - # Check delete of fallback value - del fallback['key2'] - self.assertTrue('key2' not in primary) - self.assertTrue('key2' not in fallback) + def test_overlay(self): + source = ConfigDict() + source['key'] = 'source' + overlay = source._make_overlay() + + # Overlay contains values from source + self.assertEqual(overlay['key'], 'source') + self.assertEqual(overlay.get('key'), 'source') + self.assertTrue('key' in overlay) + + # Overlay is updated with source + source['key'] = 'source2' + self.assertEqual(source['key'], 'source2') + self.assertEqual(overlay['key'], 'source2') + + # Overlay 'overlays' source (hence the name) + overlay['key'] = 'overlay' + self.assertEqual(source['key'], 'source2') + self.assertEqual(overlay['key'], 'overlay') + + # Deleting an overlayed key restores the value from source + del overlay['key'] + self.assertEqual(source['key'], 'source2') + self.assertEqual(overlay['key'], 'source2') + + # Deleting a key in the source also removes it from overlays. + del source['key'] + self.assertTrue('key' not in overlay) + self.assertTrue('key' not in source) + + # New keys in source are copied to overlay + source['key2'] = 'source' + self.assertEqual(source['key2'], 'source') + self.assertEqual(overlay['key2'], 'source') + + # New keys in overlay do not change the source + overlay['key3'] = 'overlay' + self.assertEqual(overlay['key3'], 'overlay') + self.assertTrue('key3' not in source) + + # Setting the same key in the source does not affect the overlay + source['key3'] = 'source' + self.assertEqual(source['key3'], 'source') + self.assertEqual(overlay['key3'], 'overlay') + + # But as soon as the overlayed key is deleted, the source is copied again + del overlay['key3'] + self.assertEqual(source['key3'], 'source') + self.assertEqual(overlay['key3'], 'source') + class TestINIConfigLoader(unittest.TestCase): diff --git a/test/test_mount.py b/test/test_mount.py index 8745d47..4eb5178 100644 --- a/test/test_mount.py +++ b/test/test_mount.py @@ -91,7 +91,7 @@ class TestAppMounting(ServerTestBase): def test_mount_json_bug(self): @self.subapp.route('/json') def test_cookie(): - return {'a':5} + return {'a': 5} self.app.mount('/test', self.subapp) self.assertHeader('Content-Type', 'application/json', '/test/json') diff --git a/test/test_route.py b/test/test_route.py index 7989284..477e803 100644 --- a/test/test_route.py +++ b/test/test_route.py @@ -23,8 +23,7 @@ class TestRoute(unittest.TestCase): def w(): return f() return w - - route = bottle.Route(None, None, None, d(x)) + route = bottle.Route(bottle.Bottle(), None, None, d(x)) self.assertEqual(route.get_undecorated_callback(), x) self.assertEqual(set(route.get_callback_args()), set(['a', 'b'])) @@ -35,7 +34,7 @@ class TestRoute(unittest.TestCase): return w return d - route = bottle.Route(None, None, None, d2('foo')(x)) + route = bottle.Route(bottle.Bottle(), None, None, d2('foo')(x)) self.assertEqual(route.get_undecorated_callback(), x) self.assertEqual(set(route.get_callback_args()), set(['a', 'b'])) @@ -55,7 +54,7 @@ class TestRoute(unittest.TestCase): def x(a, b): return - route = bottle.Route(None, None, None, x) + route = bottle.Route(bottle.Bottle(), None, None, x) # triggers the "TypeError: 'foo' is not a Python function" self.assertEqual(set(route.get_callback_args()), set(['a', 'b'])) @@ -64,5 +63,5 @@ class TestRoute(unittest.TestCase): def test_callback_inspection_newsig(self): env = {} eval(compile('def foo(a, *, b=5): pass', '<foo>', 'exec'), env, env) - route = bottle.Route(None, None, None, env['foo']) + route = bottle.Route(bottle.Bottle(), None, None, env['foo']) self.assertEqual(set(route.get_callback_args()), set(['a', 'b'])) |