summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMarcel Hellkamp <marc@gsites.de>2016-09-02 00:00:51 +0200
committerMarcel Hellkamp <marc@gsites.de>2016-09-25 17:28:58 +0200
commit21aeec0636748d8bf577e29da72cc3c0f0341555 (patch)
treed2413620cb6afadbb206750231a4391e86cc068f
parentc6fb8a46db3fe1200d6aff4c958cb1751c66dcd8 (diff)
downloadbottle-config-overlay.tar.gz
Rewrite 'overlay' feature for ConfigDict.config-overlay
-rwxr-xr-xbottle.py187
-rw-r--r--test/test_config.py100
-rw-r--r--test/test_mount.py2
-rw-r--r--test/test_route.py9
4 files changed, 203 insertions, 95 deletions
diff --git a/bottle.py b/bottle.py
index cb73dd8..b74a0e7 100755
--- a/bottle.py
+++ b/bottle.py
@@ -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']))