Merge lp:~vila/awsome/config-with-tests into lp:awsome

Proposed by Vincent Ladeuil
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
Reviewer Review Type Date Requested Status
Martin Packman Approve
Review via email: mp+105800@code.launchpad.net

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.

To post a comment you must log in.
Revision history for this message
Martin Packman (gz) wrote :

+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 MultilineVersion(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):
+ val = None
+ if isinstance(string, str):
+ try:
+ val = _valid_boolean_strings[string.lower()]
+ except KeyError:
+ pass
+ return val

I'd use a more compact spelling avoiding the isinstance check:

    try:
        return _valid_boolean_strings[string.lower()]
    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):
...
+def overrideEnv(test, name, value):
...
+def overrideAttr(test, obj, attr_name, new=_unitialized_attr):

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):
+ opt = config.Option('foo', default='bar',
+ default_from_env=['NO_VALUE', 'FOO', 'BAZ'])

Not isolated from NO_VALUE being set in the test process.

+ self.assertNotEquals(None, option_help)
+ # Come on, think about the user, he really wants to know what the
+ # option is about
+ self.assertIsNot(None, option_help)

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.

review: Approve
Revision history for this message
Vincent Ladeuil (vila) wrote :
Download full text (5.3 KiB)

>>>>> 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:~/src/awsome/trunk :) $ bin/awsome
Traceback (most recent call last):
  File "bin/awsome", line 43, in <module>
    credentials = Credentials.from_config(conf)
  File "/home/vila/src/awsome/trunk/awsome/users.py", line 58, in from_config
    self = cls(conf.get('aws_access_key'), conf.get('aws_secret_key'))
  File "/home/vila/src/awsome/trunk/awsome/users.py", line 25, in __init__
    AWSCredentials.__init__(self, access_key, secret_key)
  File "/home/vila/lib/python/txaws/credentials.py", line 34, in __init__
    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_scheme=passthrough doesn't
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 MultilineVersion(argparse.Action):

    > 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_string(string):
    > + val = None
    > + if isinstance(string, str):
    > + try:
    > + val = _valid_boolean_strings[string.lower()]
    > + except KeyError:
    > + pass
    > + return val

    > I'd use a more compact spelling avoiding the isinstance check:

    > try:
    > return _va...

Read more...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== added file 'awsome/config.py'
--- awsome/config.py 1970-01-01 00:00:00 +0000
+++ awsome/config.py 2012-05-15 11:25:23 +0000
@@ -0,0 +1,272 @@
1# Copyright (C) 2012 Canonical Ltd.
2# This program is free software: you can redistribute it and/or modify
3# it under the terms of the GNU Affero General Public License as
4# published by the Free Software Foundation, version 3.
5#
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU Affero General Public License for more details.
10#
11# You should have received a copy of the GNU Affero General Public License
12# along with this program. If not, see <http://www.gnu.org/licenses/>.
13
14"""Awsome configuration management.
15
16awsome can be configured:
17- from the command line,
18- via a config file.
19
20Default values can be acquired from environment variables for backwards
21compatibility during a transition period.
22"""
23
24import argparse
25import collections
26import ConfigParser
27import os
28
29import awsome
30
31class ConfigOptionValueError(StandardError):
32
33 _fmt = ('Bad value "%(value)s" for option "%(name)s".')
34
35 def __init__(self, name, value):
36 StandardError.__init__(self, self._fmt % dict(name=name, value=value))
37
38
39class Option(object):
40 """An option definition.
41
42 The option values comes from a config file or the command line.
43 """
44 def __init__(self, name, default=None, default_from_env=None,
45 help=None, convert=None, invalid=None):
46 """Build an option definition.
47
48 :param name: the name used to refer to the option.
49
50 :param default: the default value to use when none exist in the config
51 file. This is either a string that ``convert`` will convert into
52 the proper type, a callable returning a string so that ``convert``
53 can be used on the return value, or a python object that can be
54 stringified.
55
56 :param default_from_env: A list of environment variables which can
57 provide a default value. 'default' will be used only if none of the
58 variables specified here are set in the environment.
59
60 :param help: a doc string to explain the option to the user.
61
62 :param convert: a callable to convert the string representing the
63 option value in a config file.
64
65 :param invalid: the action to be taken when an invalid value is
66 encountered in a config file. This is called only when ``convert``
67 is invoked to convert a string and returns None or raise ValueError
68 or TypeError. Accepted values are: None (ignore invalid values),
69 'error' (raises ConfigOptionValueError exception).
70 """
71 self.name = name
72 # Convert the default value to a string so all values are strings
73 # internally before conversion (via from_unicode) is attempted.
74 if default is None:
75 self.default = None
76 elif isinstance(default, (str, bool, int)):
77 # Rely on python to convert the supported types to strings
78 self.default = '%s' % (default,)
79 elif callable(default):
80 self.default = default
81 else:
82 # other python objects are not expected
83 raise AssertionError('%r is not supported as a default value'
84 % (default,))
85 if default_from_env is None:
86 default_from_env = []
87 self.default_from_env = default_from_env
88 self.help = help
89 self.convert = convert
90 if invalid and invalid not in ('error',):
91 raise AssertionError("%s not supported for 'invalid'" % (invalid,))
92 self.invalid = invalid
93
94 def get_default(self):
95 value = None
96 for var in self.default_from_env:
97 try:
98 # If the env variable is defined, its value is the default one
99 value = os.environ[var]
100 break
101 except KeyError:
102 continue
103 if value is None:
104 # Otherwise, fallback to the value defined at registration
105 if callable(self.default):
106 value = self.default()
107 if not isinstance(value, str):
108 raise AssertionError(
109 'Callable default values should be strings')
110 else:
111 value = self.default
112 return value
113
114 def convert_from_string(self, string):
115 if self.convert is None or string is None:
116 # Don't convert or nothing to convert
117 return string
118 try:
119 converted = self.convert(string)
120 except (ValueError, TypeError):
121 # Invalid values are ignored
122 converted = None
123 if converted is None and self.invalid is not None:
124 # The conversion failed
125 if self.invalid == 'error':
126 raise ConfigOptionValueError(self.name, string)
127 return converted
128
129
130class MultilineVersion(argparse.Action):
131 """Alternative to argparse 'version' action supporting multiline output"""
132
133 def __init__(self, option_strings, message_format,
134 dest=argparse.SUPPRESS, default=argparse.SUPPRESS,
135 help="display version and exit", **kwargs):
136 super(MultilineVersion, self).__init__(option_strings=option_strings,
137 nargs=0, dest=dest, default=default, help=help)
138 self.fmt = message_format
139 self.details = kwargs
140
141 def __call__(self, parser, namespace, values, option_string=None):
142 parser.exit(message=self.fmt % dict(prog=parser.prog, **self.details))
143
144
145class Config(ConfigParser.ConfigParser):
146 """A container for all awsome configuration options.
147
148 This provides a single place to query for any option and can be populated
149 from the command line (providing switches for all options, see
150 ``from_cmdline``).
151
152 Registering options is mandatory.
153 """
154
155 # ConfigParser mandates a section so we use 'awsome' just in case we
156 # 'DEFAULT' for other purposes in the future.
157 # This allows removing the section parameter from .get() and .set()
158 awsome_section_name = 'awsome'
159
160 def __init__(self):
161 ConfigParser.ConfigParser.__init__(self)
162 self.add_section(self.awsome_section_name)
163
164 def get(self, name):
165 value = None
166 opt = option_registry.get(name)
167 try:
168 value = ConfigParser.ConfigParser.get(
169 self, self.awsome_section_name, name)
170 except ConfigParser.NoOptionError:
171 value = None
172 if value is None and opt is not None:
173 # Get the default value
174 value = opt.get_default()
175 if opt is not None:
176 # Convert the value
177 value = opt.convert_from_string(value)
178 return value
179
180 def set(self, name, value):
181 return ConfigParser.ConfigParser.set(self, self.awsome_section_name,
182 name, value)
183
184 def from_cmdline(self, args):
185 """Populate option values from a command line.
186
187 :param args: A list of strings as expected in sys.argv[1:].
188 """
189 parser = argparse.ArgumentParser(description="AWS API Proxy",
190 prog='awsome')
191 parser.add_argument("--version", action=MultilineVersion,
192 message_format="%(prog)s %(version)s\n%(copy)s\n",
193 version=awsome.__version__,
194 copy="(C) 2012 Canonical Ltd.")
195 # Add all registered config options as cmdline arguments
196 for name, opt in option_registry.iteritems():
197 arg_name = '--%s' % (name.replace('_', '-'),)
198 parser.add_argument(arg_name, type=str, help=opt.help)
199 args = parser.parse_args(args)
200 # Inject the parsed arguments is they are not None
201 for k,v in vars(args).items():
202 if v is not None:
203 # Option values are always strings
204 self.set(k, v)
205
206
207# Predefined converters to get proper values from strings
208
209_valid_boolean_strings = dict(yes=True, no=False,
210 y=True, n=False,
211 on=True, off=False,
212 true=True, false=False)
213_valid_boolean_strings['1'] = True
214_valid_boolean_strings['0'] = False
215
216
217def bool_from_string(string):
218 val = None
219 if isinstance(string, str):
220 try:
221 val = _valid_boolean_strings[string.lower()]
222 except KeyError:
223 pass
224 return val
225
226
227def int_from_string(string):
228 return int(string)
229
230
231class OptionRegistry(object):
232 """Register config options by their name."""
233
234 def __init__(self):
235 # We use an OrderedDict so that the registration order is preserved
236 # giving a way to display the options in the logical order used to
237 # group them.
238 self._dict = collections.OrderedDict()
239
240 def register(self, option):
241 if option.name in self._dict:
242 raise KeyError('Key %r already registered' % key)
243 self._dict[option.name] = option
244
245 def get(self, key):
246 return self._dict[key]
247
248 def remove(self, key):
249 del self._dict[key]
250
251 def iteritems(self):
252 return self._dict.iteritems()
253
254
255option_registry = OptionRegistry()
256
257# Not strictly speaking a config option as it makes no sense to set the config
258# file *inside* a config file but this helps support the command line use case
259# and provide a default value
260option_registry.register(
261 Option('config', default='/etc/awsome.conf',
262 help='''Configuration file path.'''))
263
264# Registered options (the order used below the one that wil be displayed by
265# argparse when displaying usage.
266
267option_registry.register(
268 Option('port', default='8080', convert=int_from_string,
269 help='''Local port to listen on.'''))
270option_registry.register(
271 Option('log_file', default='/var/log/awsome.log',
272 help='''Path to log file.'''))
0273
=== modified file 'awsome/tests/__init__.py'
--- awsome/tests/__init__.py 2012-05-01 09:48:32 +0000
+++ awsome/tests/__init__.py 2012-05-15 11:25:23 +0000
@@ -84,6 +84,7 @@
84 names = [84 names = [
85 'awsome',85 'awsome',
86 'blackbox',86 'blackbox',
87 'config',
87 'handlers',88 'handlers',
88 'proxy_aws',89 'proxy_aws',
89 'proxy_openstack',90 'proxy_openstack',
9091
=== added file 'awsome/tests/test_config.py'
--- awsome/tests/test_config.py 1970-01-01 00:00:00 +0000
+++ awsome/tests/test_config.py 2012-05-15 11:25:23 +0000
@@ -0,0 +1,265 @@
1# Copyright (C) 2012 Canonical Ltd.
2# This program is free software: you can redistribute it and/or modify
3# it under the terms of the GNU Affero General Public License as
4# published by the Free Software Foundation, version 3.
5#
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU Affero General Public License for more details.
10#
11# You should have received a copy of the GNU Affero General Public License
12# along with this program. If not, see <http://www.gnu.org/licenses/>.
13
14from cStringIO import StringIO
15import os
16import sys
17import unittest
18
19from awsome import (
20 config,
21 tests,
22 )
23
24
25# To correctly apply scenarios with most runners when running module directly,
26# need both load_tests (for unittest/testtools) and test_suite (for twisted)
27load_tests = tests.load_tests
28test_suite = tests.get_suite_from_name(__name__)
29
30
31def set_env_variable(name, value):
32 """Modify the environment, setting or removing the env_variable.
33
34 :param name: The environment variable to set.
35
36 :param value: The value to set the environment to. If None, then
37 the variable will be removed.
38
39 :return: The original value of the environment variable.
40 """
41 orig_val = os.environ.get(name)
42 if value is None:
43 if orig_val is not None:
44 # The variable should be deleted
45 del os.environ[name]
46 else:
47 os.environ[name] = value
48 return orig_val
49
50def overrideEnv(test, name, value):
51 """Set an environment variable, and reset it after the test.
52
53 :param test: The test case.
54
55 :param name: The environment variable name.
56
57 :param new: The value to set the variable to. If None, the
58 variable is deleted from the environment.
59
60 :returns: The actual variable value.
61 """
62 orig = set_env_variable(name, value)
63 test.addCleanup(set_env_variable, name, orig)
64 return orig
65
66
67_unitialized_attr = object()
68"""A sentinel needed to act as a default value in a method signature."""
69
70
71def overrideAttr(test, obj, attr_name, new=_unitialized_attr):
72 """Overrides an object attribute restoring it after the test.
73
74 :param test: The test case.
75
76 :param obj: The object that will be mutated.
77
78 :param attr_name: The attribute name we want to preserve/override in
79 the object.
80
81 :param new: The optional value we want to set the attribute to.
82
83 :returns: The actual attr value.
84 """
85 value = getattr(obj, attr_name)
86 # The actual value is captured by the call below
87 test.addCleanup(setattr, obj, attr_name, value)
88 if new is not _unitialized_attr:
89 setattr(obj, attr_name, new)
90 return value
91
92
93class OptionTests(unittest.TestCase):
94
95 def test_default_value(self):
96 opt = config.Option('foo', default='bar')
97 self.assertEquals('bar', opt.get_default())
98
99 def test_callable_default_value(self):
100 def bar_as_str():
101 return 'bar'
102 opt = config.Option('foo', default=bar_as_str)
103 self.assertEquals('bar', opt.get_default())
104
105 def test_default_value_from_env(self):
106 opt = config.Option('foo', default='bar', default_from_env=['FOO'])
107 overrideEnv(self, 'FOO', 'quux')
108 # Env variable provides a default taking over the option one
109 self.assertEquals('quux', opt.get_default())
110
111 def test_first_default_value_from_env_wins(self):
112 opt = config.Option('foo', default='bar',
113 default_from_env=['NO_VALUE', 'FOO', 'BAZ'])
114 overrideEnv(self, 'FOO', 'foo')
115 overrideEnv(self, 'BAZ', 'baz')
116 # The first env var set wins
117 self.assertEquals('foo', opt.get_default())
118
119 def test_not_supported_object_default_value(self):
120 self.assertRaises(AssertionError, config.Option, 'foo',
121 default=object())
122
123
124class OptionConverterMixin(object):
125
126 def assertConverted(self, expected, opt, value):
127 self.assertEquals(expected, opt.convert_from_string(value))
128
129 def assertErrors(self, opt, value):
130 self.assertRaises(config.ConfigOptionValueError,
131 opt.convert_from_string, value)
132
133 def assertConvertInvalid(self, opt, invalid_value):
134 opt.invalid = None
135 self.assertEquals(None, opt.convert_from_string(invalid_value))
136 opt.invalid = 'error'
137 self.assertErrors(opt, invalid_value)
138
139
140class OptionWithBooleanConverterTests(unittest.TestCase, OptionConverterMixin):
141
142 def get_option(self):
143 return config.Option('foo', help='A boolean.',
144 convert=config.bool_from_string)
145
146 def test_convert_invalid(self):
147 opt = self.get_option()
148 # A string that is not recognized as a boolean
149 self.assertConvertInvalid(opt, 'invalid-boolean')
150 # A list of strings is never recognized as a boolean
151 self.assertConvertInvalid(opt, ['not', 'a', 'boolean'])
152
153 def test_convert_valid(self):
154 opt = self.get_option()
155 self.assertConverted(True, opt, 'True')
156 self.assertConverted(True, opt, '1')
157 self.assertConverted(False, opt, 'False')
158
159
160class OptionWithIntegerConverterTests(unittest.TestCase, OptionConverterMixin):
161
162 def get_option(self):
163 return config.Option('foo', help='An integer.',
164 convert=config.int_from_string)
165
166 def test_convert_invalid(self):
167 opt = self.get_option()
168 # A string that is not recognized as an integer
169 self.assertConvertInvalid(opt, 'forty-two')
170 # A list of strings is never recognized as an integer
171 self.assertConvertInvalid(opt, ['a', 'list'])
172
173 def test_convert_valid(self):
174 opt = self.get_option()
175 self.assertConverted(16, opt, '16')
176
177
178class OptionRegistryTests(unittest.TestCase):
179
180 def setUp(self):
181 super(OptionRegistryTests, self).setUp()
182 # Always start with an empty registry
183 overrideAttr(self, config, 'option_registry', config.OptionRegistry())
184 self.registry = config.option_registry
185
186 def test_register(self):
187 opt = config.Option('foo')
188 self.registry.register(opt)
189 self.assertIs(opt, self.registry.get('foo'))
190
191 def test_registered_help(self):
192 opt = config.Option('foo', help='A simple option')
193 self.registry.register(opt)
194 self.assertEquals('A simple option', self.registry.get('foo').help)
195
196
197class RegisteredOptionsTests(unittest.TestCase):
198 """All registered options should verify some constraints."""
199
200 scenarios = [(key, {'option_name': key, 'option': option}) for key, option
201 in config.option_registry.iteritems()]
202
203 def setUp(self):
204 super(RegisteredOptionsTests, self).setUp()
205 self.registry = config.option_registry
206
207 def test_proper_name(self):
208 # An option should be registered under its own name, this can't be
209 # checked at registration time for the lazy ones.
210 self.assertEquals(self.option_name, self.option.name)
211
212 def test_help_is_set(self):
213 option_help = self.registry.get(self.option_name).help
214 self.assertNotEquals(None, option_help)
215 # Come on, think about the user, he really wants to know what the
216 # option is about
217 self.assertIsNot(None, option_help)
218 self.assertNotEquals('', option_help)
219
220
221class ConfigTests(unittest.TestCase):
222
223 def setUp(self):
224 self.conf = config.Config()
225
226 def test_get_unregistered_option(self):
227 self.assertRaises(KeyError, self.conf.get, 'foo')
228
229 def test_get_on_empty_config_gives_default_value(self):
230 # The default value is provided as a string at registration but .get()
231 # convert it
232 self.assertEquals(8080, self.conf.get('port'))
233
234 def test_roundtrip(self):
235 # This allows covering .set()
236 self.conf.set('config', 'foo')
237 self.assertEquals('foo', self.conf.get('config'))
238
239
240class ConfigFromCmdlineTests(unittest.TestCase):
241
242 def setUp(self):
243 self.conf = config.Config()
244
245 def test_default_conf_file(self):
246 self.conf.from_cmdline([])
247 self.assertEquals('/etc/awsome.conf', self.conf.get('config'))
248
249 def test_port(self):
250 self.conf.from_cmdline(['--port=80'])
251 self.assertEquals(80, self.conf.get('port'))
252
253 def assertEndsWith(self, s, suffix):
254 """Asserts that s ends with suffix."""
255 if not s.endswith(suffix):
256 raise AssertionError('string %r does not end with %r' % (s, suffix))
257
258 def test_error(self):
259 err = StringIO()
260 overrideAttr(self, sys, 'stderr', err)
261 with self.assertRaises(SystemExit) as cm:
262 self.conf.from_cmdline(['foo', 'bar'])
263 lines = err.getvalue().splitlines()
264 self.assertEndsWith(lines[-1], 'error: unrecognized arguments: foo bar')
265
0266
=== modified file 'bin/awsome'
--- bin/awsome 2012-05-08 09:04:13 +0000
+++ bin/awsome 2012-05-15 11:25:23 +0000
@@ -16,6 +16,9 @@
1616
17import os17import os
18import sys18import sys
19
20
21# Find the awsome lib along the script itself
19sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))22sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
2023
21import ConfigParser24import ConfigParser

Subscribers

People subscribed via source and target branches