Merge lp:~vila/awsome/config-with-tests into lp:awsome
- config-with-tests
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 229 |
Proposed branch: | lp:~vila/awsome/config-with-tests |
Merge into: | lp:awsome |
Diff against target: |
572 lines (+541/-0) 4 files modified
awsome/config.py (+272/-0) awsome/tests/__init__.py (+1/-0) awsome/tests/test_config.py (+265/-0) bin/awsome (+3/-0) |
To merge this branch: | bzr merge lp:~vila/awsome/config-with-tests |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Martin Packman | Approve | ||
Review via email: mp+105800@code.launchpad.net |
Commit message
Description of the change
This add a config implementation with tests simplifying the handling of all
config options:
- each option is declared only once,
- a single section is used in the config file (see below for rationale),
- command line switches are recognized for all options,
- options can get their default value from the environment variables.
We don't need multiple sections for the few options we handle, but since
ConfigParser requires one, I used 'awsome'.
Using a single section also allows to mirror all options as command line
switches and remove the need to always specify a section name when querying
the config.
I used bzr config as a guide to get a simplified design and most of the
tests.
This is a pre-requisite to another proposal I'll file later.
The idea is that we need a simple way to setup tests and environment
variables are not a good solution for that. That's not mentioning that some
of them are currently acquired once (users.py for example) and cannot easily
be changed for a test.
Vincent Ladeuil (vila) wrote : | # |
>>>>> Martin Packman writes:
> Review: Approve
> +Default values can be acquired from environment variables for backwards
> +compatibility during a transition period.
> So, I disagree somewhat here as we've discussed before. For
> development, I like not having to copy secret values into a config
> file to test against different clouds, and I don't see how that
> changes.
Ha, I failed to fully explain what I have in mind here, sorry.
So, the idea is that you setup a test cloud as part of your dev env and,
separately, fire the test suite.
The test suite checks the availability of various test clouds and inject
the corresponding scenarios to exercise them.
Roughly you do (imaginary commands):
start-cloud essex-precise
make test
hack
make test
stop-cloud essex-precise
hack, edit test cloud config file
start-cloud essex-precise
make test
Obviously if you have more than one test cloud, relying on env vars is a
no-go.
And if/when we have such test clouds, manual testing should not be
necessary anymore (or become so awkward we abandon it ;).
Additionally, running bin/awsome without any prior sourcing:
vila:~/
Traceback (most recent call last):
File "bin/awsome", line 43, in <module>
credentials = Credentials.
File "/home/
self = cls(conf.
File "/home/
AWSCredenti
File "/home/
raise ValueError("Could not find %s" % ENV_ACCESS_KEY)
ValueError: Could not find AWS_ACCESS_KEY_ID
This demonstrates (don't let the 'from_config' distract you, this is an
old bug also encountered before this mp) that all of us have been
running our manual tests in sourced environments without realizing
*this* issue (there may be others).
'all' here includes me even if --os_auth_
require to set any access key... and is a valid setup for awsome on a
server.
The trap is easy to fall into :-/
Far too easy.
The comment is a reminder of the above issues/missing features, we can
continue to discuss ;)
> + if not isinstance(value, str):
> + raise AssertionError(
> + 'Callable default values should be strings')
> Assertions without any context at all are a bit annoying.
I've added the option name.
> +class MultilineVersio
> Slightly confusing copying this in in this mp as it's only removed
> from bin/awsome in the followup, but not harmful.
Yeah, that was the easiest way to separate the mps.
> +def bool_from_
> + val = None
> + if isinstance(string, str):
> + try:
> + val = _valid_
> + except KeyError:
> + pass
> + return val
> I'd use a more compact spelling avoiding the isinstance check:
> try:
> return _va...
Preview Diff
1 | === added file 'awsome/config.py' |
2 | --- awsome/config.py 1970-01-01 00:00:00 +0000 |
3 | +++ awsome/config.py 2012-05-15 11:25:23 +0000 |
4 | @@ -0,0 +1,272 @@ |
5 | +# Copyright (C) 2012 Canonical Ltd. |
6 | +# This program is free software: you can redistribute it and/or modify |
7 | +# it under the terms of the GNU Affero General Public License as |
8 | +# published by the Free Software Foundation, version 3. |
9 | +# |
10 | +# This program is distributed in the hope that it will be useful, |
11 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
12 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
13 | +# GNU Affero General Public License for more details. |
14 | +# |
15 | +# You should have received a copy of the GNU Affero General Public License |
16 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
17 | + |
18 | +"""Awsome configuration management. |
19 | + |
20 | +awsome can be configured: |
21 | +- from the command line, |
22 | +- via a config file. |
23 | + |
24 | +Default values can be acquired from environment variables for backwards |
25 | +compatibility during a transition period. |
26 | +""" |
27 | + |
28 | +import argparse |
29 | +import collections |
30 | +import ConfigParser |
31 | +import os |
32 | + |
33 | +import awsome |
34 | + |
35 | +class ConfigOptionValueError(StandardError): |
36 | + |
37 | + _fmt = ('Bad value "%(value)s" for option "%(name)s".') |
38 | + |
39 | + def __init__(self, name, value): |
40 | + StandardError.__init__(self, self._fmt % dict(name=name, value=value)) |
41 | + |
42 | + |
43 | +class Option(object): |
44 | + """An option definition. |
45 | + |
46 | + The option values comes from a config file or the command line. |
47 | + """ |
48 | + def __init__(self, name, default=None, default_from_env=None, |
49 | + help=None, convert=None, invalid=None): |
50 | + """Build an option definition. |
51 | + |
52 | + :param name: the name used to refer to the option. |
53 | + |
54 | + :param default: the default value to use when none exist in the config |
55 | + file. This is either a string that ``convert`` will convert into |
56 | + the proper type, a callable returning a string so that ``convert`` |
57 | + can be used on the return value, or a python object that can be |
58 | + stringified. |
59 | + |
60 | + :param default_from_env: A list of environment variables which can |
61 | + provide a default value. 'default' will be used only if none of the |
62 | + variables specified here are set in the environment. |
63 | + |
64 | + :param help: a doc string to explain the option to the user. |
65 | + |
66 | + :param convert: a callable to convert the string representing the |
67 | + option value in a config file. |
68 | + |
69 | + :param invalid: the action to be taken when an invalid value is |
70 | + encountered in a config file. This is called only when ``convert`` |
71 | + is invoked to convert a string and returns None or raise ValueError |
72 | + or TypeError. Accepted values are: None (ignore invalid values), |
73 | + 'error' (raises ConfigOptionValueError exception). |
74 | + """ |
75 | + self.name = name |
76 | + # Convert the default value to a string so all values are strings |
77 | + # internally before conversion (via from_unicode) is attempted. |
78 | + if default is None: |
79 | + self.default = None |
80 | + elif isinstance(default, (str, bool, int)): |
81 | + # Rely on python to convert the supported types to strings |
82 | + self.default = '%s' % (default,) |
83 | + elif callable(default): |
84 | + self.default = default |
85 | + else: |
86 | + # other python objects are not expected |
87 | + raise AssertionError('%r is not supported as a default value' |
88 | + % (default,)) |
89 | + if default_from_env is None: |
90 | + default_from_env = [] |
91 | + self.default_from_env = default_from_env |
92 | + self.help = help |
93 | + self.convert = convert |
94 | + if invalid and invalid not in ('error',): |
95 | + raise AssertionError("%s not supported for 'invalid'" % (invalid,)) |
96 | + self.invalid = invalid |
97 | + |
98 | + def get_default(self): |
99 | + value = None |
100 | + for var in self.default_from_env: |
101 | + try: |
102 | + # If the env variable is defined, its value is the default one |
103 | + value = os.environ[var] |
104 | + break |
105 | + except KeyError: |
106 | + continue |
107 | + if value is None: |
108 | + # Otherwise, fallback to the value defined at registration |
109 | + if callable(self.default): |
110 | + value = self.default() |
111 | + if not isinstance(value, str): |
112 | + raise AssertionError( |
113 | + 'Callable default values should be strings') |
114 | + else: |
115 | + value = self.default |
116 | + return value |
117 | + |
118 | + def convert_from_string(self, string): |
119 | + if self.convert is None or string is None: |
120 | + # Don't convert or nothing to convert |
121 | + return string |
122 | + try: |
123 | + converted = self.convert(string) |
124 | + except (ValueError, TypeError): |
125 | + # Invalid values are ignored |
126 | + converted = None |
127 | + if converted is None and self.invalid is not None: |
128 | + # The conversion failed |
129 | + if self.invalid == 'error': |
130 | + raise ConfigOptionValueError(self.name, string) |
131 | + return converted |
132 | + |
133 | + |
134 | +class MultilineVersion(argparse.Action): |
135 | + """Alternative to argparse 'version' action supporting multiline output""" |
136 | + |
137 | + def __init__(self, option_strings, message_format, |
138 | + dest=argparse.SUPPRESS, default=argparse.SUPPRESS, |
139 | + help="display version and exit", **kwargs): |
140 | + super(MultilineVersion, self).__init__(option_strings=option_strings, |
141 | + nargs=0, dest=dest, default=default, help=help) |
142 | + self.fmt = message_format |
143 | + self.details = kwargs |
144 | + |
145 | + def __call__(self, parser, namespace, values, option_string=None): |
146 | + parser.exit(message=self.fmt % dict(prog=parser.prog, **self.details)) |
147 | + |
148 | + |
149 | +class Config(ConfigParser.ConfigParser): |
150 | + """A container for all awsome configuration options. |
151 | + |
152 | + This provides a single place to query for any option and can be populated |
153 | + from the command line (providing switches for all options, see |
154 | + ``from_cmdline``). |
155 | + |
156 | + Registering options is mandatory. |
157 | + """ |
158 | + |
159 | + # ConfigParser mandates a section so we use 'awsome' just in case we |
160 | + # 'DEFAULT' for other purposes in the future. |
161 | + # This allows removing the section parameter from .get() and .set() |
162 | + awsome_section_name = 'awsome' |
163 | + |
164 | + def __init__(self): |
165 | + ConfigParser.ConfigParser.__init__(self) |
166 | + self.add_section(self.awsome_section_name) |
167 | + |
168 | + def get(self, name): |
169 | + value = None |
170 | + opt = option_registry.get(name) |
171 | + try: |
172 | + value = ConfigParser.ConfigParser.get( |
173 | + self, self.awsome_section_name, name) |
174 | + except ConfigParser.NoOptionError: |
175 | + value = None |
176 | + if value is None and opt is not None: |
177 | + # Get the default value |
178 | + value = opt.get_default() |
179 | + if opt is not None: |
180 | + # Convert the value |
181 | + value = opt.convert_from_string(value) |
182 | + return value |
183 | + |
184 | + def set(self, name, value): |
185 | + return ConfigParser.ConfigParser.set(self, self.awsome_section_name, |
186 | + name, value) |
187 | + |
188 | + def from_cmdline(self, args): |
189 | + """Populate option values from a command line. |
190 | + |
191 | + :param args: A list of strings as expected in sys.argv[1:]. |
192 | + """ |
193 | + parser = argparse.ArgumentParser(description="AWS API Proxy", |
194 | + prog='awsome') |
195 | + parser.add_argument("--version", action=MultilineVersion, |
196 | + message_format="%(prog)s %(version)s\n%(copy)s\n", |
197 | + version=awsome.__version__, |
198 | + copy="(C) 2012 Canonical Ltd.") |
199 | + # Add all registered config options as cmdline arguments |
200 | + for name, opt in option_registry.iteritems(): |
201 | + arg_name = '--%s' % (name.replace('_', '-'),) |
202 | + parser.add_argument(arg_name, type=str, help=opt.help) |
203 | + args = parser.parse_args(args) |
204 | + # Inject the parsed arguments is they are not None |
205 | + for k,v in vars(args).items(): |
206 | + if v is not None: |
207 | + # Option values are always strings |
208 | + self.set(k, v) |
209 | + |
210 | + |
211 | +# Predefined converters to get proper values from strings |
212 | + |
213 | +_valid_boolean_strings = dict(yes=True, no=False, |
214 | + y=True, n=False, |
215 | + on=True, off=False, |
216 | + true=True, false=False) |
217 | +_valid_boolean_strings['1'] = True |
218 | +_valid_boolean_strings['0'] = False |
219 | + |
220 | + |
221 | +def bool_from_string(string): |
222 | + val = None |
223 | + if isinstance(string, str): |
224 | + try: |
225 | + val = _valid_boolean_strings[string.lower()] |
226 | + except KeyError: |
227 | + pass |
228 | + return val |
229 | + |
230 | + |
231 | +def int_from_string(string): |
232 | + return int(string) |
233 | + |
234 | + |
235 | +class OptionRegistry(object): |
236 | + """Register config options by their name.""" |
237 | + |
238 | + def __init__(self): |
239 | + # We use an OrderedDict so that the registration order is preserved |
240 | + # giving a way to display the options in the logical order used to |
241 | + # group them. |
242 | + self._dict = collections.OrderedDict() |
243 | + |
244 | + def register(self, option): |
245 | + if option.name in self._dict: |
246 | + raise KeyError('Key %r already registered' % key) |
247 | + self._dict[option.name] = option |
248 | + |
249 | + def get(self, key): |
250 | + return self._dict[key] |
251 | + |
252 | + def remove(self, key): |
253 | + del self._dict[key] |
254 | + |
255 | + def iteritems(self): |
256 | + return self._dict.iteritems() |
257 | + |
258 | + |
259 | +option_registry = OptionRegistry() |
260 | + |
261 | +# Not strictly speaking a config option as it makes no sense to set the config |
262 | +# file *inside* a config file but this helps support the command line use case |
263 | +# and provide a default value |
264 | +option_registry.register( |
265 | + Option('config', default='/etc/awsome.conf', |
266 | + help='''Configuration file path.''')) |
267 | + |
268 | +# Registered options (the order used below the one that wil be displayed by |
269 | +# argparse when displaying usage. |
270 | + |
271 | +option_registry.register( |
272 | + Option('port', default='8080', convert=int_from_string, |
273 | + help='''Local port to listen on.''')) |
274 | +option_registry.register( |
275 | + Option('log_file', default='/var/log/awsome.log', |
276 | + help='''Path to log file.''')) |
277 | |
278 | === modified file 'awsome/tests/__init__.py' |
279 | --- awsome/tests/__init__.py 2012-05-01 09:48:32 +0000 |
280 | +++ awsome/tests/__init__.py 2012-05-15 11:25:23 +0000 |
281 | @@ -84,6 +84,7 @@ |
282 | names = [ |
283 | 'awsome', |
284 | 'blackbox', |
285 | + 'config', |
286 | 'handlers', |
287 | 'proxy_aws', |
288 | 'proxy_openstack', |
289 | |
290 | === added file 'awsome/tests/test_config.py' |
291 | --- awsome/tests/test_config.py 1970-01-01 00:00:00 +0000 |
292 | +++ awsome/tests/test_config.py 2012-05-15 11:25:23 +0000 |
293 | @@ -0,0 +1,265 @@ |
294 | +# Copyright (C) 2012 Canonical Ltd. |
295 | +# This program is free software: you can redistribute it and/or modify |
296 | +# it under the terms of the GNU Affero General Public License as |
297 | +# published by the Free Software Foundation, version 3. |
298 | +# |
299 | +# This program is distributed in the hope that it will be useful, |
300 | +# but WITHOUT ANY WARRANTY; without even the implied warranty of |
301 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
302 | +# GNU Affero General Public License for more details. |
303 | +# |
304 | +# You should have received a copy of the GNU Affero General Public License |
305 | +# along with this program. If not, see <http://www.gnu.org/licenses/>. |
306 | + |
307 | +from cStringIO import StringIO |
308 | +import os |
309 | +import sys |
310 | +import unittest |
311 | + |
312 | +from awsome import ( |
313 | + config, |
314 | + tests, |
315 | + ) |
316 | + |
317 | + |
318 | +# To correctly apply scenarios with most runners when running module directly, |
319 | +# need both load_tests (for unittest/testtools) and test_suite (for twisted) |
320 | +load_tests = tests.load_tests |
321 | +test_suite = tests.get_suite_from_name(__name__) |
322 | + |
323 | + |
324 | +def set_env_variable(name, value): |
325 | + """Modify the environment, setting or removing the env_variable. |
326 | + |
327 | + :param name: The environment variable to set. |
328 | + |
329 | + :param value: The value to set the environment to. If None, then |
330 | + the variable will be removed. |
331 | + |
332 | + :return: The original value of the environment variable. |
333 | + """ |
334 | + orig_val = os.environ.get(name) |
335 | + if value is None: |
336 | + if orig_val is not None: |
337 | + # The variable should be deleted |
338 | + del os.environ[name] |
339 | + else: |
340 | + os.environ[name] = value |
341 | + return orig_val |
342 | + |
343 | +def overrideEnv(test, name, value): |
344 | + """Set an environment variable, and reset it after the test. |
345 | + |
346 | + :param test: The test case. |
347 | + |
348 | + :param name: The environment variable name. |
349 | + |
350 | + :param new: The value to set the variable to. If None, the |
351 | + variable is deleted from the environment. |
352 | + |
353 | + :returns: The actual variable value. |
354 | + """ |
355 | + orig = set_env_variable(name, value) |
356 | + test.addCleanup(set_env_variable, name, orig) |
357 | + return orig |
358 | + |
359 | + |
360 | +_unitialized_attr = object() |
361 | +"""A sentinel needed to act as a default value in a method signature.""" |
362 | + |
363 | + |
364 | +def overrideAttr(test, obj, attr_name, new=_unitialized_attr): |
365 | + """Overrides an object attribute restoring it after the test. |
366 | + |
367 | + :param test: The test case. |
368 | + |
369 | + :param obj: The object that will be mutated. |
370 | + |
371 | + :param attr_name: The attribute name we want to preserve/override in |
372 | + the object. |
373 | + |
374 | + :param new: The optional value we want to set the attribute to. |
375 | + |
376 | + :returns: The actual attr value. |
377 | + """ |
378 | + value = getattr(obj, attr_name) |
379 | + # The actual value is captured by the call below |
380 | + test.addCleanup(setattr, obj, attr_name, value) |
381 | + if new is not _unitialized_attr: |
382 | + setattr(obj, attr_name, new) |
383 | + return value |
384 | + |
385 | + |
386 | +class OptionTests(unittest.TestCase): |
387 | + |
388 | + def test_default_value(self): |
389 | + opt = config.Option('foo', default='bar') |
390 | + self.assertEquals('bar', opt.get_default()) |
391 | + |
392 | + def test_callable_default_value(self): |
393 | + def bar_as_str(): |
394 | + return 'bar' |
395 | + opt = config.Option('foo', default=bar_as_str) |
396 | + self.assertEquals('bar', opt.get_default()) |
397 | + |
398 | + def test_default_value_from_env(self): |
399 | + opt = config.Option('foo', default='bar', default_from_env=['FOO']) |
400 | + overrideEnv(self, 'FOO', 'quux') |
401 | + # Env variable provides a default taking over the option one |
402 | + self.assertEquals('quux', opt.get_default()) |
403 | + |
404 | + def test_first_default_value_from_env_wins(self): |
405 | + opt = config.Option('foo', default='bar', |
406 | + default_from_env=['NO_VALUE', 'FOO', 'BAZ']) |
407 | + overrideEnv(self, 'FOO', 'foo') |
408 | + overrideEnv(self, 'BAZ', 'baz') |
409 | + # The first env var set wins |
410 | + self.assertEquals('foo', opt.get_default()) |
411 | + |
412 | + def test_not_supported_object_default_value(self): |
413 | + self.assertRaises(AssertionError, config.Option, 'foo', |
414 | + default=object()) |
415 | + |
416 | + |
417 | +class OptionConverterMixin(object): |
418 | + |
419 | + def assertConverted(self, expected, opt, value): |
420 | + self.assertEquals(expected, opt.convert_from_string(value)) |
421 | + |
422 | + def assertErrors(self, opt, value): |
423 | + self.assertRaises(config.ConfigOptionValueError, |
424 | + opt.convert_from_string, value) |
425 | + |
426 | + def assertConvertInvalid(self, opt, invalid_value): |
427 | + opt.invalid = None |
428 | + self.assertEquals(None, opt.convert_from_string(invalid_value)) |
429 | + opt.invalid = 'error' |
430 | + self.assertErrors(opt, invalid_value) |
431 | + |
432 | + |
433 | +class OptionWithBooleanConverterTests(unittest.TestCase, OptionConverterMixin): |
434 | + |
435 | + def get_option(self): |
436 | + return config.Option('foo', help='A boolean.', |
437 | + convert=config.bool_from_string) |
438 | + |
439 | + def test_convert_invalid(self): |
440 | + opt = self.get_option() |
441 | + # A string that is not recognized as a boolean |
442 | + self.assertConvertInvalid(opt, 'invalid-boolean') |
443 | + # A list of strings is never recognized as a boolean |
444 | + self.assertConvertInvalid(opt, ['not', 'a', 'boolean']) |
445 | + |
446 | + def test_convert_valid(self): |
447 | + opt = self.get_option() |
448 | + self.assertConverted(True, opt, 'True') |
449 | + self.assertConverted(True, opt, '1') |
450 | + self.assertConverted(False, opt, 'False') |
451 | + |
452 | + |
453 | +class OptionWithIntegerConverterTests(unittest.TestCase, OptionConverterMixin): |
454 | + |
455 | + def get_option(self): |
456 | + return config.Option('foo', help='An integer.', |
457 | + convert=config.int_from_string) |
458 | + |
459 | + def test_convert_invalid(self): |
460 | + opt = self.get_option() |
461 | + # A string that is not recognized as an integer |
462 | + self.assertConvertInvalid(opt, 'forty-two') |
463 | + # A list of strings is never recognized as an integer |
464 | + self.assertConvertInvalid(opt, ['a', 'list']) |
465 | + |
466 | + def test_convert_valid(self): |
467 | + opt = self.get_option() |
468 | + self.assertConverted(16, opt, '16') |
469 | + |
470 | + |
471 | +class OptionRegistryTests(unittest.TestCase): |
472 | + |
473 | + def setUp(self): |
474 | + super(OptionRegistryTests, self).setUp() |
475 | + # Always start with an empty registry |
476 | + overrideAttr(self, config, 'option_registry', config.OptionRegistry()) |
477 | + self.registry = config.option_registry |
478 | + |
479 | + def test_register(self): |
480 | + opt = config.Option('foo') |
481 | + self.registry.register(opt) |
482 | + self.assertIs(opt, self.registry.get('foo')) |
483 | + |
484 | + def test_registered_help(self): |
485 | + opt = config.Option('foo', help='A simple option') |
486 | + self.registry.register(opt) |
487 | + self.assertEquals('A simple option', self.registry.get('foo').help) |
488 | + |
489 | + |
490 | +class RegisteredOptionsTests(unittest.TestCase): |
491 | + """All registered options should verify some constraints.""" |
492 | + |
493 | + scenarios = [(key, {'option_name': key, 'option': option}) for key, option |
494 | + in config.option_registry.iteritems()] |
495 | + |
496 | + def setUp(self): |
497 | + super(RegisteredOptionsTests, self).setUp() |
498 | + self.registry = config.option_registry |
499 | + |
500 | + def test_proper_name(self): |
501 | + # An option should be registered under its own name, this can't be |
502 | + # checked at registration time for the lazy ones. |
503 | + self.assertEquals(self.option_name, self.option.name) |
504 | + |
505 | + def test_help_is_set(self): |
506 | + option_help = self.registry.get(self.option_name).help |
507 | + self.assertNotEquals(None, option_help) |
508 | + # Come on, think about the user, he really wants to know what the |
509 | + # option is about |
510 | + self.assertIsNot(None, option_help) |
511 | + self.assertNotEquals('', option_help) |
512 | + |
513 | + |
514 | +class ConfigTests(unittest.TestCase): |
515 | + |
516 | + def setUp(self): |
517 | + self.conf = config.Config() |
518 | + |
519 | + def test_get_unregistered_option(self): |
520 | + self.assertRaises(KeyError, self.conf.get, 'foo') |
521 | + |
522 | + def test_get_on_empty_config_gives_default_value(self): |
523 | + # The default value is provided as a string at registration but .get() |
524 | + # convert it |
525 | + self.assertEquals(8080, self.conf.get('port')) |
526 | + |
527 | + def test_roundtrip(self): |
528 | + # This allows covering .set() |
529 | + self.conf.set('config', 'foo') |
530 | + self.assertEquals('foo', self.conf.get('config')) |
531 | + |
532 | + |
533 | +class ConfigFromCmdlineTests(unittest.TestCase): |
534 | + |
535 | + def setUp(self): |
536 | + self.conf = config.Config() |
537 | + |
538 | + def test_default_conf_file(self): |
539 | + self.conf.from_cmdline([]) |
540 | + self.assertEquals('/etc/awsome.conf', self.conf.get('config')) |
541 | + |
542 | + def test_port(self): |
543 | + self.conf.from_cmdline(['--port=80']) |
544 | + self.assertEquals(80, self.conf.get('port')) |
545 | + |
546 | + def assertEndsWith(self, s, suffix): |
547 | + """Asserts that s ends with suffix.""" |
548 | + if not s.endswith(suffix): |
549 | + raise AssertionError('string %r does not end with %r' % (s, suffix)) |
550 | + |
551 | + def test_error(self): |
552 | + err = StringIO() |
553 | + overrideAttr(self, sys, 'stderr', err) |
554 | + with self.assertRaises(SystemExit) as cm: |
555 | + self.conf.from_cmdline(['foo', 'bar']) |
556 | + lines = err.getvalue().splitlines() |
557 | + self.assertEndsWith(lines[-1], 'error: unrecognized arguments: foo bar') |
558 | + |
559 | |
560 | === modified file 'bin/awsome' |
561 | --- bin/awsome 2012-05-08 09:04:13 +0000 |
562 | +++ bin/awsome 2012-05-15 11:25:23 +0000 |
563 | @@ -16,6 +16,9 @@ |
564 | |
565 | import os |
566 | import sys |
567 | + |
568 | + |
569 | +# Find the awsome lib along the script itself |
570 | sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) |
571 | |
572 | import ConfigParser |
+Default values can be acquired from environment variables for backwards
+compatibility during a transition period.
So, I disagree somewhat here as we've discussed before. For development, I like not having to copy secret values into a config file to test against different clouds, and I don't see how that changes.
+ if not isinstance(value, str):
+ raise AssertionError(
+ 'Callable default values should be strings')
Assertions without any context at all are a bit annoying.
+class MultilineVersio n(argparse. Action) :
Slightly confusing copying this in in this mp as it's only removed from bin/awsome in the followup, but not harmful.
+def bool_from_ string( string) : boolean_ strings[ string. lower() ]
+ val = None
+ if isinstance(string, str):
+ try:
+ val = _valid_
+ except KeyError:
+ pass
+ return val
I'd use a more compact spelling avoiding the isinstance check:
try: boolean_ strings[ string. lower() ]
return _valid_
except (AttributeError, KeyError):
return None
Or `str.lower(string)` catching TypeError if you really want to be picky about the type.
+def set_env_ variable( name, value): d_attr) :
...
+def overrideEnv(test, name, value):
...
+def overrideAttr(test, obj, attr_name, new=_unitialize
I'd stick all these in awsome.tests so we have a one place with all the extra testing bits we needed to bring in.
+ def test_first_ default_ value_from_ env_wins( self): Option( 'foo', default='bar', from_env= ['NO_VALUE' , 'FOO', 'BAZ'])
+ opt = config.
+ default_
Not isolated from NO_VALUE being set in the test process.
+ self.assertNotE quals(None, option_help) t(None, option_help)
+ # Come on, think about the user, he really wants to know what the
+ # option is about
+ self.assertIsNo
First assertion is meaningless given the second, nothing ordinary compares equal to None except itself.
+ def test_error(self):
Not wild about this test, overriding standard streams and catching SystemExit is a little dodgy in tests. Would prefer some refactoring to avoid that, but I suspect argparse doesn't make our lives easy here so it's not that important.