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
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

Subscribers

People subscribed via source and target branches