Codebase list python-django-etcd-settings / 183373d
New upstream snapshot. Debian Janitor 2 years ago
13 changed file(s) with 13 addition(s) and 618 deletion(s). Raw diff Collapse all Expand all
8787
8888 * ``DJES_WSGI_FILE``: path to the ``wsgi.py`` file for the django
8989 project. If not None, the monitoring of environment configuration will
90 perform a ``touch`` of the file everytime the env_defaults are updated, so
90 perform a ``touch`` of the file every time the env_defaults are updated, so
9191 that all processes consuming settings from ``django.conf`` can consume the
9292 latest settings available as well
9393 The path can be absolute or relative to the 'manage.py' file.
0 python-django-etcd-settings (0.1.13+dfsg-4) UNRELEASED; urgency=medium
0 python-django-etcd-settings (0.1.15+git20171003.1.5a176a8-1) UNRELEASED; urgency=medium
11
22 [ Debian Janitor ]
33 * Set upstream metadata fields: Bug-Database, Bug-Submit, Repository,
1313 * Apply multi-arch hints.
1414 + python-django-etcd-settings-doc: Add Multi-Arch: foreign.
1515 * Bump debhelper from deprecated 9 to 13.
16 * New upstream snapshot.
1617
17 -- Debian Janitor <janitor@jelmer.uk> Sat, 02 May 2020 09:29:16 +0000
18 -- Debian Janitor <janitor@jelmer.uk> Mon, 15 Nov 2021 01:59:07 -0000
1819
1920 python-django-etcd-settings (0.1.13+dfsg-3) unstable; urgency=medium
2021
33
44 At the command line::
55
6 $ pip install ssh://git@stash.kpnnl.local:7999/de/django-etcd-settings.git
6 $ pip install django-etcd-settings
+0
-11
etcd_settings/loader.py less more
0 from .manager import EtcdConfigManager
1
2
3 def get_overwrites(env, dev_params, etcd_details):
4 overwrites = {}
5 if etcd_details is not None:
6 mgr = EtcdConfigManager(dev_params, **etcd_details)
7 overwrites = mgr.get_env_defaults(env)
8 else:
9 overwrites = EtcdConfigManager.get_dev_params(dev_params)
10 return overwrites
+0
-209
etcd_settings/manager.py less more
0 import json
1 import logging
2 import re
3 import time
4 from importlib import import_module
5 from os import utime
6
7 from etcd import Client, EtcdException, EtcdKeyNotFound
8
9 from .utils import (
10 CustomJSONEncoder, attrs_to_dir, byteify, custom_json_decoder_hook,
11 threaded,
12 )
13
14
15 class EtcdConfigInvalidValueError(Exception):
16 def __init__(self, key, raw_value, value_error):
17 self.key = key
18 self.raw_value = raw_value
19 self.value_error = value_error
20 super(EtcdConfigInvalidValueError, self).__init__(
21 "Invalid value for key '{}'. Raising '{}', because of value: '{}'"
22 .format(key, value_error, raw_value))
23
24
25 class EtcdClusterState(object):
26 etcd_index = 0
27
28
29 class EtcdConfigManager(object):
30 def __init__(
31 self, dev_params=None, prefix='config', protocol='http',
32 host='localhost', port=2379, username=None, password=None,
33 long_polling_timeout=50, long_polling_safety_delay=5):
34 self._client = Client(
35 host=host, port=port, protocol=protocol, allow_redirect=True,
36 username=username, password=password)
37 # Overriding retries for urllib3.PoolManager.connection_pool_kw
38 self._client.http.connection_pool_kw['retries'] = 0
39 self._base_config_path = prefix
40 self._dev_params = dev_params
41 self._base_config_set_path = "{}/extensions" \
42 .format(self._base_config_path)
43 r = ('^(?P<path>{}/(?:extensions/)?'
44 '(?P<envorset>[\w\-\.]+))/(?P<key>.+)$')
45 self._key_regex = re.compile(r.format(self._base_config_path))
46 self.long_polling_timeout = long_polling_timeout
47 self.long_polling_safety_delay = long_polling_safety_delay
48 self._init_logger()
49
50 def _init_logger(self):
51 self.logger = logging.getLogger('etcd_config_manager')
52 logger_console_handler = logging.StreamHandler()
53 logger_console_handler.setLevel(logging.ERROR)
54 self.logger.addHandler(logger_console_handler)
55
56 def _env_defaults_path(self, env='test'):
57 return "{}/{}".format(self._base_config_path, env)
58
59 def _config_set_path(self, set_name):
60 return "{}/{}".format(self._base_config_set_path, set_name)
61
62 def _encode_config_key(self, k):
63 return k.lower().replace('_', '/')
64
65 def _decode_config_key(self, k):
66 [env_or_set, key_path] = re.sub(
67 self._key_regex, '\g<envorset>|\g<key>', k).split('|')
68 return env_or_set, key_path.upper().replace('/', '_')
69
70 def _encode_config_value(self, val):
71 return json.dumps(val, cls=CustomJSONEncoder)
72
73 def _decode_config_value(self, val):
74 decoded = json.loads(val, object_hook=custom_json_decoder_hook)
75 return byteify(decoded)
76
77 def _process_response_set(self, rset, env_defaults=True):
78 d = {}
79 EtcdClusterState.etcd_index = rset.etcd_index
80 for leaf in rset.leaves:
81 try:
82 config_set, key = self._decode_config_key(leaf.key)
83 except ValueError:
84 info = "An error occurred when processing an EtcdResponse"
85 if not env_defaults:
86 info += " (is '{}' a directory?)".format(
87 self._base_config_set_path)
88 self.logger.warning(info)
89 else:
90 if leaf.value is not None:
91 try:
92 value = self._decode_config_value(leaf.value)
93 except ValueError as e:
94 raise EtcdConfigInvalidValueError(
95 leaf.key, leaf.value, e)
96
97 if env_defaults:
98 d[key] = value
99 else:
100 if config_set not in d:
101 d[config_set] = {}
102 d[config_set][key] = value
103 return d
104
105 @staticmethod
106 def get_dev_params(mod):
107 params = {}
108 if mod:
109 params = attrs_to_dir(import_module(mod))
110 return params
111
112 def get_env_defaults(self, env):
113 res = self._client.read(
114 self._env_defaults_path(env),
115 recursive=True)
116 conf = self._process_response_set(res)
117 conf.update(EtcdConfigManager.get_dev_params(self._dev_params))
118 return conf
119
120 def get_config_sets(self):
121 conf = {}
122 try:
123 res = self._client.read(
124 self._base_config_set_path,
125 recursive=True)
126 conf = self._process_response_set(res, env_defaults=False)
127 except EtcdKeyNotFound:
128 self.logger.warning(
129 "Unable to find config sets at '{}' (expected a dict)",
130 self._base_config_set_path)
131 return conf
132
133 @threaded(daemon=True)
134 def monitor_env_defaults(
135 self, env, conf={}, wsgi_file=None, max_events=None):
136 processed_events = 0
137 for event in self._watch(
138 self._env_defaults_path(env), conf, wsgi_file, max_events):
139 if event is not None:
140 conf.update(self._process_response_set(event))
141 conf.update(EtcdConfigManager.get_dev_params(self._dev_params))
142 if wsgi_file:
143 with open(wsgi_file, 'a'):
144 utime(wsgi_file, None)
145 processed_events += 1
146 return processed_events
147
148 @threaded(daemon=True)
149 def monitor_config_sets(self, conf={}, max_events=None):
150 processed_events = 0
151 for event in self._watch(
152 self._base_config_set_path, conf=conf, max_events=max_events):
153 if event is not None:
154 conf.update(
155 self._process_response_set(event, env_defaults=False))
156 processed_events += 1
157 return processed_events
158
159 def _watch(self, path, conf={}, wsgi_file=None, max_events=None):
160 i = 0
161 while (max_events is None) or (i < max_events):
162 try:
163 i += 1
164 index = EtcdClusterState.etcd_index
165 if index > 0:
166 index = index + 1
167 res = self._client.watch(
168 path,
169 index=index,
170 recursive=True,
171 timeout=self.long_polling_timeout)
172 else:
173 res = self._client.read(path, recursive=True)
174 yield res
175 except Exception as e:
176 if not (isinstance(e, EtcdException)
177 and ('timed out' in str(e))):
178 self.logger.error("Long Polling Error: {}".format(e))
179 time.sleep(self.long_polling_safety_delay)
180 yield None
181
182 def set_env_defaults(self, env, conf={}):
183 path = self._env_defaults_path(env)
184 errors = {}
185 for k, v in conf.items():
186 if k.isupper():
187 try:
188 encoded_key = self._encode_config_key(k)
189 self._client.write(
190 "{}/{}".format(path, encoded_key),
191 self._encode_config_value(v))
192 except Exception as e:
193 errors[k] = str(e)
194 return errors
195
196 def set_config_sets(self, config_sets={}):
197 errors = {}
198 for set_name, config_set in config_sets.items():
199 path = self._config_set_path(set_name)
200 for k, v in config_set.items():
201 if k.isupper():
202 try:
203 self._client.write(
204 "{}/{}".format(path, self._encode_config_key(k)),
205 self._encode_config_value(v))
206 except Exception as e:
207 errors[k] = str(e)
208 return errors
22 from importlib import import_module
33
44 from django.conf import settings as django_settings
5 from etcd_config.manager import EtcdConfigManager
6 from etcd_config.utils import attrs_to_dir
57
6 from .manager import EtcdConfigManager
7 from .utils import (
8 attrs_to_dir, copy_if_mutable, dict_rec_update, find_project_root,
9 )
8 from .utils import copy_if_mutable, dict_rec_update, find_project_root
109
1110
1211 class EtcdSettingsProxy(object):
00 import copy
1 import datetime
2 import json
3 import logging
41 import os
5 import sys
62 from collections import Mapping
7 from functools import wraps
8 from threading import Thread
9
10 import six
11 from dateutil.parser import parse as parse_date
12
13
14 def attrs_to_dir(mod):
15 data = {}
16 for attr in dir(mod):
17 if attr == attr.upper():
18 data[attr] = getattr(mod, attr)
19 return data
203
214
225 def dict_rec_update(d, u):
2912 else:
3013 d[k] = u[k]
3114 return d
32
33
34 class Task(Thread):
35 """
36 The Threaded object returned by the @threaded decorator below
37 """
38
39 def __init__(self, method, *args, **kwargs):
40 super(Task, self).__init__()
41 self.method = method
42 self.args = args
43 self.kwargs = kwargs
44 self._result = None
45 self.__exc_info = None
46
47 def run(self):
48 try:
49 self._result = self.method(*self.args, **self.kwargs)
50 except:
51 self.__exc_info = sys.exc_info()
52
53 @property
54 def result(self):
55 self.join()
56 if self.__exc_info is not None:
57 six.reraise(*self.__exc_info)
58 return self._result
59
60
61 def threaded(function=None, daemon=False):
62
63 def wrapper_factory(func):
64
65 @wraps(func)
66 def get_thread(*args, **kwargs):
67 t = Task(func, *args, **kwargs)
68 if daemon:
69 t.daemon = True
70 t.start()
71 return t
72
73 return get_thread
74
75 if function:
76 return wrapper_factory(function)
77 else:
78 return wrapper_factory
79
80
81 class CustomJSONEncoder(json.JSONEncoder):
82
83 custom_type_key = '_custom_type'
84 custom_type_value_key = 'value'
85 DECODERS = {'datetime': parse_date}
86
87 def default(self, obj):
88 if isinstance(obj, datetime.datetime):
89 return {self.custom_type_key: 'datetime',
90 self.custom_type_value_key: obj.isoformat()}
91 else:
92 return super(CustomJSONEncoder, self).default(obj)
93
94
95 def custom_json_decoder_hook(obj):
96
97 ct = obj.get(CustomJSONEncoder.custom_type_key, None)
98 if ct is not None:
99 value = obj.get(CustomJSONEncoder.custom_type_value_key)
100 return CustomJSONEncoder.DECODERS[ct](value)
101 else:
102 return obj
10315
10416
10517 def find_project_root(root_indicator='manage.py', current=os.getcwd()):
12032 if type(value) in (dict, list):
12133 return copy.deepcopy(value)
12234 return value
123
124
125 def byteify(input):
126 if isinstance(input, dict):
127 return {byteify(key): byteify(value) for key, value in input.items()}
128 elif isinstance(input, list):
129 return [byteify(element) for element in input]
130 elif (sys.version_info.major == 2) and (isinstance(input, unicode)):
131 return input.encode('utf-8')
132 else:
133 return input
134
135
136 class IgnoreMaxEtcdRetries(logging.Filter):
137 """
138 Skip etcd.client MaxRetryError on timeout
139 """
140
141 def __init__(self, name='etcd.client'):
142 super(IgnoreMaxEtcdRetries, self).__init__(name)
143
144 def filter(self, record):
145 msg = '{}'.format(record.args)
146 return not (
147 'MaxRetryError' in msg and
148 'Read timed out' in msg
149 )
00 Django>=1.7.5
1 python-etcd>=0.4.1
2 python-dateutil>=2.2
31 six>=1.10.0,<2.0.0
2 etcd-config>=1.0.4
+0
-10
tests/test_loader.py less more
0 from django.test import TestCase
1 from etcd_settings.loader import get_overwrites
2
3
4 class TestLoader(TestCase):
5
6 def test_get_overwrites_without_etcd(self):
7 overwrites = get_overwrites(
8 'unittest', 'tests.loader_dev_params', None)
9 self.assertEqual('bar', overwrites['FOO'])
+0
-259
tests/test_manager.py less more
0 import datetime
1 import json
2 import logging
3 import os
4 import time
5
6 from django.test import TestCase
7 from etcd import EtcdKeyNotFound
8 from etcd_settings.manager import (
9 EtcdClusterState, EtcdConfigInvalidValueError, EtcdConfigManager,
10 )
11
12 from .conftest import settings
13
14
15 class TestEtcdConfigManager(TestCase):
16 longMessage = True
17
18 def _dataset_with_empty_dir(self):
19 key = os.path.join(self.mgr._env_defaults_path(self.env), 'dir/empty')
20 self.mgr._client.write(key, None, dir=True)
21 value = self.mgr._client.get(key)
22 return key, value
23
24 def _dataset_with_invalid_json(self):
25 key = os.path.join(
26 self.mgr._env_defaults_path(self.env), 'json/invalid')
27 self.mgr._client.set(key, '{')
28 value = self.mgr._client.get(key)
29 return key, value
30
31 def _dataset_for_defaults(self):
32 dataset = {}
33 k1 = os.path.join(self.mgr._env_defaults_path(self.env), 'foo/bar')
34 dataset[k1] = '"baz"'
35 k2 = os.path.join(self.mgr._env_defaults_path(self.env), 'foo/baz')
36 dataset[k2] = '"bar"'
37 k3 = os.path.join(self.mgr._env_defaults_path(self.env), 'foobarbaz')
38 dataset[k3] = '"superbaz"'
39 expected_env = {
40 'FOO_BAR': 'baz',
41 'FOO_BAZ': 'bar',
42 'FOOBARBAZ': 'superbaz',
43 }
44 for k, v in dataset.items():
45 self.mgr._client.set(k, v)
46 return dataset.keys(), expected_env
47
48 def _dataset_for_configsets(self):
49 dataset = {}
50 k1 = os.path.join(self.mgr._config_set_path('foo'), 'bar')
51 dataset[k1] = '1'
52 k2 = os.path.join(self.mgr._config_set_path('foo'), 'baz')
53 dataset[k2] = '2'
54 k3 = os.path.join(self.mgr._config_set_path('foo.bar'), 'baz')
55 dataset[k3] = '1'
56 k4 = os.path.join(self.mgr._config_set_path('foo.bar'), 'bazbaz')
57 dataset[k4] = '2'
58 k5 = os.path.join(self.mgr._config_set_path('foo.bar-zoo'), 'bar')
59 dataset[k5] = '1'
60 expected_sets = {
61 'foo': {'BAR': 1, 'BAZ': 2},
62 'foo.bar': {'BAZ': 1, 'BAZBAZ': 2},
63 'foo.bar-zoo': {'BAR': 1},
64 }
65 for k, v in dataset.items():
66 self.mgr._client.set(k, v)
67 return dataset.keys(), expected_sets
68
69 def setUp(self):
70
71 self.env = 'unittest'
72 EtcdClusterState.etcd_index = 0
73 self.mgr = EtcdConfigManager(
74 dev_params=None, prefix=settings.ETCD_PREFIX, protocol='http',
75 host=settings.ETCD_HOST, port=settings.ETCD_PORT,
76 username=settings.ETCD_USERNAME, password=settings.ETCD_PASSWORD,
77 long_polling_timeout=0.1, long_polling_safety_delay=0.1
78 )
79 for l in self.mgr.logger.handlers:
80 l.setLevel(logging.CRITICAL)
81
82 def tearDown(self):
83 def try_unless_not_found(f):
84 try:
85 f()
86 except EtcdKeyNotFound:
87 pass
88
89 def clean_env():
90 self.mgr._client.delete(
91 self.mgr._env_defaults_path(self.env), recursive=True)
92
93 def clean_extensions():
94 self.mgr._client.delete(
95 self.mgr._base_config_set_path, recursive=True)
96
97 try_unless_not_found(clean_extensions)
98 try_unless_not_found(clean_env)
99
100 def test_init_logger(self):
101 self.assertIsNotNone(self.mgr.logger)
102
103 def test_encode_config_key(self):
104 self.assertEqual(
105 'foo/bar/baz',
106 self.mgr._encode_config_key('FOO_BAR_BAZ'))
107
108 def test_decode_env_config_key(self):
109 key = 'FOO_BAR'
110 s = '{}/{}/foo/bar'.format(self.mgr._base_config_path, self.env)
111 self.assertEqual((self.env, key), self.mgr._decode_config_key(s))
112
113 def test_decode_set_config_key(self):
114 key = 'FOO_BAR'
115 configset = 'unit.test'
116 s = '{}/{}/foo/bar'.format(self.mgr._base_config_set_path, configset)
117 self.assertEqual((configset, key), self.mgr._decode_config_key(s))
118
119 def test_encode_config_value(self):
120 self.assertEqual(
121 '"abcde"',
122 self.mgr._encode_config_value('abcde'))
123 self.assertEqual(
124 '112',
125 self.mgr._encode_config_value(112))
126 self.assertEqual(
127 # Tuples are lost in encoding, should be avoided as config values
128 '[1, "b"]',
129 self.mgr._encode_config_value((1, 'b')))
130 encoded_config = self.mgr._encode_config_value(dict(foo=1, bar='baz'))
131 self.assertEqual(
132 json.loads('{"foo": 1, "bar": "baz"}'),
133 json.loads(encoded_config))
134
135 def test_process_response_set_empty(self):
136 key, value = self._dataset_with_empty_dir()
137 self.assertEqual({}, self.mgr._process_response_set(value))
138
139 def test_process_response_exception_handling(self):
140 with self.assertRaises(EtcdConfigInvalidValueError) as excContext:
141 key, value = self._dataset_with_invalid_json()
142 self.mgr._process_response_set(value)
143
144 exc = excContext.exception
145
146 self.assertEqual(key, exc.key)
147 self.assertEqual(value.value, exc.raw_value)
148 self.assertIn(key, str(exc), "Expect key in message")
149 self.assertIn(
150 "Expecting", str(exc), msg="Expect detailed error message")
151 self.assertIn(
152 "line", str(exc), msg="Expect line number in error message")
153 self.assertIn(
154 "column", str(exc), msg="Expect column number in error message")
155 self.assertIn(value.value, str(exc), msg="Expect invalid value")
156
157 def test_decode_config_value(self):
158 self.assertEqual(
159 'abcde',
160 self.mgr._decode_config_value('"abcde"'))
161 self.assertEqual(
162 112,
163 self.mgr._decode_config_value('112'))
164 self.assertEqual(
165 dict(foo=1, bar='baz'),
166 self.mgr._decode_config_value('{"foo": 1, "bar": "baz"}'))
167 self.assertEqual(
168 [1, 'b'],
169 self.mgr._decode_config_value('[1, "b"]'))
170
171 def test_custom_encoding_decoding_values(self):
172 d = datetime.datetime(2015, 10, 9, 8, 7, 6)
173 encoded_d = self.mgr._encode_config_value(d)
174 decoded_d = self.mgr._decode_config_value(encoded_d)
175 self.assertEqual(True, isinstance(decoded_d, datetime.datetime))
176 self.assertEqual(d.isoformat(), decoded_d.isoformat())
177 self.assertEqual(d, decoded_d)
178
179 def test_get_env_defaults(self):
180 keys, expected = self._dataset_for_defaults()
181 self.assertEqual(expected, self.mgr.get_env_defaults(self.env))
182
183 def test_get_config_sets(self):
184 keys, expected_sets = self._dataset_for_configsets()
185 self.assertEqual(expected_sets, self.mgr.get_config_sets())
186
187 def test_monitor_env_defaults(self):
188 keys, expected_env = self._dataset_for_defaults()
189 d = {}
190 old_etcd_index = EtcdClusterState.etcd_index
191 t = self.mgr.monitor_env_defaults(env=self.env, conf=d, max_events=1)
192 self.assertEqual(1, t.result)
193 self.assertEqual(expected_env, d)
194 self.assertGreater(EtcdClusterState.etcd_index, old_etcd_index)
195
196 def test_monitor_config_sets(self):
197 keys, expected_sets = self._dataset_for_configsets()
198 d = {}
199 old_etcd_index = EtcdClusterState.etcd_index
200 t = self.mgr.monitor_config_sets(conf=d, max_events=1)
201 self.assertEqual(1, t.result)
202 self.assertEqual(expected_sets, d)
203 self.assertGreater(EtcdClusterState.etcd_index, old_etcd_index)
204
205 def test_monitors_delay_and_continue_on_exception(self):
206 d = {}
207 max_events = 2
208 lambdas = [
209 lambda: self.mgr.monitor_env_defaults(
210 self.env, conf=d, max_events=max_events),
211 lambda: self.mgr.monitor_config_sets(
212 conf=d, max_events=max_events),
213 ]
214 try:
215 self.mgr._base_config_path = 'Unknown'
216 for l in lambdas:
217 t0 = time.time()
218 t = l()
219 self.assertEqual(max_events, t.result)
220 self.assertEqual(
221 True,
222 (time.time() - t0)
223 > (max_events * self.mgr.long_polling_safety_delay)
224 )
225 self.assertEqual(False, t.is_alive())
226 finally:
227 self.mgr._base_config_path = settings.ETCD_PREFIX
228
229 def test_monitors_continue_on_etcd_exception(self):
230 d = {}
231 max_events = 2
232 lambdas = [
233 lambda: self.mgr.monitor_env_defaults(
234 self.env, conf=d, max_events=max_events),
235 lambda: self.mgr.monitor_config_sets(
236 conf=d, max_events=max_events),
237 ]
238 try:
239 old_etcd_index = EtcdClusterState.etcd_index
240 self.mgr._etcd_index = 999990
241 for l in lambdas:
242 t0 = time.time()
243 t = l()
244 self.assertEqual(max_events, t.result)
245 net_time = time.time() - t0
246 self.assertGreater(
247 net_time,
248 (max_events * self.mgr.long_polling_timeout)
249 )
250 self.assertLess(
251 net_time,
252 (max_events *
253 (self.mgr.long_polling_safety_delay
254 + self.mgr.long_polling_timeout))
255 )
256 self.assertEqual(False, t.is_alive())
257 finally:
258 EtcdClusterState.etcd_index = old_etcd_index
55 from django.http import HttpRequest
66 from django.test import TestCase
77 from django.test.utils import override_settings
8 from etcd_settings.loader import get_overwrites
9 from etcd_settings.manager import EtcdClusterState, EtcdConfigManager
8 from etcd_config.loader import get_overwrites
9 from etcd_config.manager import EtcdClusterState, EtcdConfigManager
1010 from etcd_settings.proxy import EtcdSettingsProxy
1111 from mock import MagicMock
1212
1616 @override_settings(
1717 DJES_ETCD_DETAILS=settings.ETCD_DETAILS,
1818 DJES_ENV=settings.ETCD_ENV,
19 DJES_REQUEST_GETTER='etcd_settings.utils.threaded',
19 DJES_REQUEST_GETTER='etcd_config.utils.threaded',
2020 E=0
2121 )
2222 class TestEtcdSettingsProxy(TestCase):
00 import logging
11 import unittest
22
3 from etcd_settings import utils
3 from etcd_config import utils
44
55
66 class TestLoggingFilter(unittest.TestCase):