|
1
|
|
|
# -*- coding: utf-8 -*- |
|
2
|
|
|
"""A specialized map implementation to manage configuration and context information. |
|
3
|
|
|
|
|
4
|
|
|
Features: |
|
5
|
|
|
* Uses YAML configuration files |
|
6
|
|
|
* Use environment variables to override configs |
|
7
|
|
|
* Can pass a list of required parameters at initialization |
|
8
|
|
|
* Works with encrypted files |
|
9
|
|
|
* Accepts multiple config files |
|
10
|
|
|
* Can query information from consul |
|
11
|
|
|
* Does type coercion on strings |
|
12
|
|
|
* Composable configs |
|
13
|
|
|
* Ordered merge: downstream configs will override upstream |
|
14
|
|
|
* Reload from sources on SIGHUP |
|
15
|
|
|
|
|
16
|
|
|
Available source types: |
|
17
|
|
|
* Environment variables |
|
18
|
|
|
* Yaml file on file system |
|
19
|
|
|
* Yaml file in S3 |
|
20
|
|
|
* Consul |
|
21
|
|
|
* Hashicorp Vault |
|
22
|
|
|
|
|
23
|
|
|
Notes: |
|
24
|
|
|
* Keys are case-insensitive. |
|
25
|
|
|
|
|
26
|
|
|
""" |
|
27
|
|
|
from __future__ import absolute_import, division, print_function |
|
28
|
|
|
|
|
29
|
|
|
import inspect |
|
30
|
|
|
import logging |
|
31
|
|
|
import os |
|
32
|
|
|
import signal |
|
33
|
|
|
|
|
34
|
|
|
from .compat import string_types |
|
35
|
|
|
from .decorators import memoize, memoizemethod |
|
36
|
|
|
from .exceptions import AssignmentError, NotFoundError |
|
37
|
|
|
from .path import PackageFile |
|
38
|
|
|
from .type_coercion import listify |
|
39
|
|
|
from .type_coercion import typify |
|
40
|
|
|
|
|
41
|
|
|
log = logging.getLogger(__name__) |
|
42
|
|
|
|
|
43
|
|
|
|
|
44
|
|
|
@memoize |
|
45
|
|
|
def make_env_key(app_name, key): |
|
46
|
|
|
"""Creates an environment key-equivalent for the given key""" |
|
47
|
|
|
key = key.replace('-', '_').replace(' ', '_') |
|
48
|
|
|
return "_".join((x.upper() for x in (app_name, key))) |
|
49
|
|
|
|
|
50
|
|
|
|
|
51
|
|
|
@memoize |
|
52
|
|
|
def reverse_env_key(app_name, key): |
|
53
|
|
|
app = app_name.upper() + '_' |
|
54
|
|
|
assert key.startswith(app), "{0} is not a(n) {1} environment key".format(key, app) |
|
55
|
|
|
return key[len(app):].lower() |
|
56
|
|
|
|
|
57
|
|
|
|
|
58
|
|
|
class Configuration(object): |
|
59
|
|
|
"""A map implementation to manage configuration and context information. Values |
|
60
|
|
|
can be accessed (read, not assigned) as either a dict lookup (e.g. `config[key]`) or as an |
|
61
|
|
|
attribute (e.g. `config.key`). |
|
62
|
|
|
|
|
63
|
|
|
This class makes the foundational assumption of a yaml configuration file, as values in yaml |
|
64
|
|
|
are typed. |
|
65
|
|
|
|
|
66
|
|
|
This class allows overriding configuration keys with environment variables. Given an app name |
|
67
|
|
|
`foo` and a config parameter `bar: 15`, setting a `FOO_BAR` environment variable to `22` will |
|
68
|
|
|
override the value of `bar`. The type of `22` remains `int` because the underlying value of |
|
69
|
|
|
`15` is used to infer the type of the `FOO_BAR` environment variable. When an underlying |
|
70
|
|
|
parameter does not exist in a config file, the type is intelligently guessed. |
|
71
|
|
|
|
|
72
|
|
|
Args: |
|
73
|
|
|
app_name (str) |
|
74
|
|
|
config_sources (str or list, optional) |
|
75
|
|
|
required_parameters (iter, optional) |
|
76
|
|
|
|
|
77
|
|
|
Raises: |
|
78
|
|
|
InitializationError: on instantiation, when `required_parameters` are not found |
|
79
|
|
|
warns: on instantiation, when a given `config_file` cannot be read |
|
80
|
|
|
NotFoundError: when requesting a key that does not exist |
|
81
|
|
|
|
|
82
|
|
|
Examples: |
|
83
|
|
|
>>> for (key, value) in [('FOO_BAR', 22), ('FOO_BAZ', 'yes'), ('FOO_BANG', 'monkey')]: |
|
84
|
|
|
... os.environ[key] = str(value) |
|
85
|
|
|
|
|
86
|
|
|
>>> context = Configuration('foo') |
|
87
|
|
|
>>> context.bar, context.bar.__class__ |
|
88
|
|
|
(22, <... 'int'>) |
|
89
|
|
|
>>> context['baz'], type(context['baz']) |
|
90
|
|
|
(True, <... 'bool'>) |
|
91
|
|
|
>>> context.bang, type(context.bang) |
|
92
|
|
|
('monkey', <... 'str'>) |
|
93
|
|
|
|
|
94
|
|
|
""" |
|
95
|
|
|
|
|
96
|
|
|
def __init__(self, appname, config_sources=None, required_parameters=None, package=None): |
|
97
|
|
|
self.appname = appname |
|
98
|
|
|
self.package = package or inspect.getmodule(self).__package__ |
|
99
|
|
|
|
|
100
|
|
|
# Indicates if sources are being loaded for the first time or are being reloaded. |
|
101
|
|
|
self.__initial_load = True |
|
102
|
|
|
|
|
103
|
|
|
# The ordered list of sources from which to load key, value pairs. |
|
104
|
|
|
self.__sources = list() |
|
105
|
|
|
|
|
106
|
|
|
# The object that holds the actual key, value pairs after they're loaded from sources. |
|
107
|
|
|
# Keys are stored as all lower-case. Access by key is provided in a case-insensitive way. |
|
108
|
|
|
self._config_map = dict() |
|
109
|
|
|
|
|
110
|
|
|
self._registered_env_keys = set() # TODO: add explanation comments |
|
111
|
|
|
self._required_keys = set(listify(required_parameters)) |
|
112
|
|
|
|
|
113
|
|
|
if hasattr(signal, 'SIGHUP'): # Reload config on SIGHUP (UNIX only) |
|
114
|
|
|
self.__set_up_sighup_handler() |
|
115
|
|
|
|
|
116
|
|
|
self.__load_environment_keys() |
|
117
|
|
|
self.append_sources(config_sources) |
|
118
|
|
|
|
|
119
|
|
|
def append_sources(self, config_sources): |
|
120
|
|
|
force_reload = True |
|
121
|
|
|
for source in listify(config_sources): |
|
122
|
|
|
self.__append_source(source, force_reload) |
|
123
|
|
|
|
|
124
|
|
|
def append_required(self, required_parameters): |
|
125
|
|
|
self._required_keys.update(listify(required_parameters)) |
|
126
|
|
|
|
|
127
|
|
|
def __append_source(self, source, force_reload=False, _parent_source=None): |
|
128
|
|
|
source.parent_config = self |
|
129
|
|
|
self.__load_source(source, force_reload) |
|
130
|
|
|
source.parent_source = _parent_source |
|
131
|
|
|
self.__sources.append(source) |
|
132
|
|
|
|
|
133
|
|
|
def verify(self): |
|
134
|
|
|
self.__ensure_required_keys() |
|
135
|
|
|
return self |
|
136
|
|
|
|
|
137
|
|
|
def set_env(self, key, value): |
|
138
|
|
|
"""Sets environment variables by prepending the app_name to `key`. Also registers the |
|
139
|
|
|
environment variable with the instance object preventing an otherwise-required call to |
|
140
|
|
|
`reload()`. |
|
141
|
|
|
""" |
|
142
|
|
|
os.environ[make_env_key(self.appname, key)] = str(value) # must coerce to string |
|
143
|
|
|
self._registered_env_keys.add(key) |
|
144
|
|
|
self._clear_memoization() |
|
145
|
|
|
|
|
146
|
|
|
def unset_env(self, key): |
|
147
|
|
|
"""Removes an environment variable using the prepended app_name convention with `key`.""" |
|
148
|
|
|
os.environ.pop(make_env_key(self.appname, key), None) |
|
149
|
|
|
self._registered_env_keys.discard(key) |
|
150
|
|
|
self._clear_memoization() |
|
151
|
|
|
|
|
152
|
|
|
def _reload(self, force=False): |
|
153
|
|
|
"""Reloads the configuration from the file and environment variables. Useful if using |
|
154
|
|
|
`os.environ` instead of this class' `set_env` method, or if the underlying configuration |
|
155
|
|
|
file is changed externally. |
|
156
|
|
|
""" |
|
157
|
|
|
self._config_map = dict() |
|
158
|
|
|
self._registered_env_keys = set() |
|
159
|
|
|
self.__reload_sources(force) |
|
160
|
|
|
self.__load_environment_keys() |
|
161
|
|
|
self.verify() |
|
162
|
|
|
self._clear_memoization() |
|
163
|
|
|
|
|
164
|
|
|
@memoizemethod # memoized for performance; always use self.set_env() instead of os.setenv() |
|
165
|
|
|
def __getitem__(self, key): |
|
166
|
|
|
key = key.lower() |
|
167
|
|
|
if key in self._registered_env_keys: |
|
168
|
|
|
from_env = os.getenv(make_env_key(self.appname, key)) |
|
169
|
|
|
from_sources = self._config_map.get(key) |
|
170
|
|
|
return typify(from_env, type(from_sources) if from_sources is not None else None) |
|
171
|
|
|
else: |
|
172
|
|
|
try: |
|
173
|
|
|
return self._config_map[key] |
|
174
|
|
|
except KeyError as e: |
|
175
|
|
|
raise NotFoundError(e) |
|
176
|
|
|
|
|
177
|
|
|
def __getattr__(self, key): |
|
178
|
|
|
return self[key] |
|
179
|
|
|
|
|
180
|
|
|
def get(self, key, default=None): |
|
181
|
|
|
try: |
|
182
|
|
|
return self[key] |
|
183
|
|
|
except KeyError: |
|
184
|
|
|
return default |
|
185
|
|
|
|
|
186
|
|
|
def __setitem__(self, *args): |
|
187
|
|
|
raise AssignmentError() |
|
188
|
|
|
|
|
189
|
|
|
def __iter__(self): |
|
190
|
|
|
for key in self._registered_env_keys | set(self._config_map.keys()): |
|
191
|
|
|
yield key |
|
192
|
|
|
|
|
193
|
|
|
def items(self): |
|
194
|
|
|
for key in self: |
|
195
|
|
|
yield key, self[key] |
|
196
|
|
|
|
|
197
|
|
|
def __load_source(self, source, force_reload=False): |
|
198
|
|
|
if force_reload and source.parent_source: |
|
199
|
|
|
# TODO: double-check case of reload without force reload for chained configs |
|
200
|
|
|
return |
|
201
|
|
|
|
|
202
|
|
|
items = source.dump(force_reload) |
|
203
|
|
|
if source.provides and not set(source.provides).issubset(items): |
|
204
|
|
|
raise NotImplementedError() # TODO: fix this |
|
205
|
|
|
|
|
206
|
|
|
additional_requirements = items.pop('additional_requirements', None) |
|
207
|
|
|
if isinstance(additional_requirements, string_types): |
|
208
|
|
|
additional_requirements = additional_requirements.split(',') |
|
209
|
|
|
self._required_keys |= set(listify(additional_requirements)) |
|
210
|
|
|
|
|
211
|
|
|
additional_sources = items.pop('additional_sources', None) |
|
212
|
|
|
|
|
213
|
|
|
self._config_map.update(items) |
|
214
|
|
|
|
|
215
|
|
|
if additional_sources: |
|
216
|
|
|
for src in additional_sources: |
|
217
|
|
|
class_name, kwargs = src.popitem() |
|
218
|
|
|
additional_source = globals()[class_name](**kwargs) |
|
219
|
|
|
self.__append_source(additional_source, force_reload, source) |
|
220
|
|
|
|
|
221
|
|
|
def __load_environment_keys(self): |
|
222
|
|
|
app_prefix = self.appname.upper() + '_' |
|
223
|
|
|
for env_key in os.environ: |
|
224
|
|
|
if env_key.startswith(app_prefix): |
|
225
|
|
|
self._registered_env_keys.add(reverse_env_key(self.appname, env_key)) |
|
226
|
|
|
# We don't actually add values to _config_map here. Rather, they're pulled |
|
227
|
|
|
# directly from os.environ at __getitem__ time. This allows for type casting |
|
228
|
|
|
# environment variables if possible. |
|
229
|
|
|
|
|
230
|
|
|
def __ensure_required_keys(self): |
|
231
|
|
|
available_keys = self._registered_env_keys | set(self._config_map.keys()) |
|
232
|
|
|
missing_keys = self._required_keys - available_keys |
|
233
|
|
|
if missing_keys: |
|
234
|
|
|
raise EnvironmentError("Required key(s) not found in environment\n" |
|
235
|
|
|
" or configuration sources.\n" |
|
236
|
|
|
" Missing Keys: {0}".format(list(missing_keys))) |
|
237
|
|
|
|
|
238
|
|
|
def _clear_memoization(self): |
|
239
|
|
|
self.__dict__.pop('_memoized_results', None) |
|
240
|
|
|
|
|
241
|
|
|
def __set_up_sighup_handler(self): |
|
242
|
|
|
def sighup_handler(signum, frame): |
|
243
|
|
|
if signum != signal.SIGHUP: |
|
244
|
|
|
return |
|
245
|
|
|
self._reload(True) |
|
246
|
|
|
if callable(self.__previous_sighup_handler): |
|
247
|
|
|
self.__previous_sighup_handler(signum, frame) |
|
248
|
|
|
self.__previous_sighup_handler = signal.getsignal(signal.SIGHUP) |
|
249
|
|
|
signal.signal(signal.SIGHUP, sighup_handler) |
|
250
|
|
|
|
|
251
|
|
|
|
|
252
|
|
|
class Source(object): |
|
253
|
|
|
_items = None |
|
254
|
|
|
_provides = None |
|
255
|
|
|
_parent_source = None |
|
256
|
|
|
|
|
257
|
|
|
@property |
|
258
|
|
|
def provides(self): |
|
259
|
|
|
return self._provides |
|
260
|
|
|
|
|
261
|
|
|
@property |
|
262
|
|
|
def items(self): |
|
263
|
|
|
return self.dump() |
|
264
|
|
|
|
|
265
|
|
|
def load(self): |
|
266
|
|
|
"""Must return a key, value dict""" |
|
267
|
|
|
raise NotImplementedError() # pragma: no cover |
|
268
|
|
|
|
|
269
|
|
|
def dump(self, force_reload=False): |
|
270
|
|
|
if self._items is None or force_reload: |
|
271
|
|
|
self._items = self.load() |
|
272
|
|
|
return self._items |
|
273
|
|
|
|
|
274
|
|
|
@property |
|
275
|
|
|
def parent_config(self): |
|
276
|
|
|
return self._parent_config |
|
277
|
|
|
|
|
278
|
|
|
@parent_config.setter |
|
279
|
|
|
def parent_config(self, parent_config): |
|
280
|
|
|
self._parent_config = parent_config |
|
281
|
|
|
|
|
282
|
|
|
@property |
|
283
|
|
|
def parent_source(self): |
|
284
|
|
|
return self._parent_source |
|
285
|
|
|
|
|
286
|
|
|
@parent_source.setter |
|
287
|
|
|
def parent_source(self, parent_source): |
|
288
|
|
|
self._parent_source = parent_source |
|
289
|
|
|
|
|
290
|
|
|
|
|
291
|
|
|
class YamlSource(Source): |
|
292
|
|
|
|
|
293
|
|
|
def __init__(self, location, provides=None): |
|
294
|
|
|
self._location = location |
|
295
|
|
|
self._provides = provides if provides else None |
|
296
|
|
|
|
|
297
|
|
|
def load(self): |
|
298
|
|
|
with PackageFile(self._location, self.parent_config.package) as fh: |
|
299
|
|
|
import yaml |
|
300
|
|
|
contents = yaml.load(fh) |
|
301
|
|
|
if self.provides is None: |
|
302
|
|
|
return contents |
|
303
|
|
|
else: |
|
304
|
|
|
return dict((key, contents[key]) for key in self.provides) |
|
305
|
|
|
|
|
306
|
|
|
|
|
307
|
|
|
class EnvironmentMappedSource(Source): |
|
308
|
|
|
"""Load a full Source object given the value of an environment variable.""" |
|
309
|
|
|
|
|
310
|
|
|
def __init__(self, envvar, sourcemap): |
|
311
|
|
|
self._envvar = envvar |
|
312
|
|
|
self._sourcemap = sourcemap |
|
313
|
|
|
|
|
314
|
|
|
def load(self): |
|
315
|
|
|
mapped_source = self._sourcemap[self.parent_config[self._envvar]] |
|
316
|
|
|
mapped_source.parent_config = self.parent_config |
|
317
|
|
|
params = mapped_source.load() |
|
318
|
|
|
return params |
|
319
|
|
|
|