Skip to content

Instantly share code, notes, and snippets.

@cdunklau
Last active August 29, 2015 14:10
Show Gist options
  • Select an option

  • Save cdunklau/c9d674066cf816721c21 to your computer and use it in GitHub Desktop.

Select an option

Save cdunklau/c9d674066cf816721c21 to your computer and use it in GitHub Desktop.
import re
from ConfigParser import NoOptionError
class ValidationFailed(Exception):
def __init__(self, reason):
self.reason = reason
def __str__(self):
return repr(self)
def __repr__(self):
return '<ValidationFailed: {0!r}>'.format(self.reason)
class ConfigurationTypeMaker(object):
"""
Simplifies the creation of configuration classes.
Example usage::
from ConfigParser import RawConfigParser
class _BaseFlarbleConfig(object):
def __init__(self, config_filename):
self.parser = RawConfigParser()
self.config_filename = config_filename
def load(self, file_like=None):
if file_like is None:
self.parser.read(self.config_filename)
else:
self.parser.readfp(file_like)
config_type_maker = ConfigurationTypeMaker(_BaseFlarbleConfig)
@config_type_maker.field('flarble', 'iterations', 1)
def iterations_validator(value):
try:
intval = int(value)
except ValueError:
raise ValidationFailed(
'{0!r} is not a valid integer'.format(intval)
if intval < 1:
raise ValidationFailed(
'Expected positive integer, got {0!r}'.format(
intval)
return intval
FlarbleConfig = config_type_maker.make_type('FlarbleConfig')
assert FlarbleConfig.field_names == ('iterations,)
import StringIO
config = FlarbleConfig('flarble.conf')
config.load(StringIO.StringIO('[flarble]\niterations: 5\n'))
# The field_names attribute is useful for validating all fields
# at once, instead of just on first access.
for name in config.field_names:
try:
getattr(config, name)
except ValidationFailed as e:
print 'Failed to validate field {0!r}: {1!r}'.format(
name, e.reason)
raise
assert config.iterations == 5
# default works
config = FlarbleConfig('flarble.conf')
config.load(StringIO.StringIO('[flarble]\n'))
assert config.iterations == 1
The type given in the constructor is used as the base type for
the class to be created by the `make_type` method. Its instances
must provide a ``ConfigParser`` instance as an attribute named
"parser".
The generated class will also be given a class attribute called
`field_names`, which will be a tuple of the names of the
generated properties.
Option names will be used as the property names on the generated
class, therefore it is an error to use names that are not Python
identifiers, and the names must not conflict with attributes on
the configuration class, including 'parser' and 'field_names'.
"""
base_type = None
"Base configuration class"
fields = None
"Maps option_name to tuple of (section_name, default, validator)"
def __init__(self, base_type):
self.base_type = base_type
self.fields = {}
def field(self, section_name, option_name, default_value=None):
"""
Decorator for config field validators.
:param section_name: Name of the config section
:param option_name: Name of the config option
:param default_value: Default value used if the field is not
found; if set to None, the field is
required
The validator function will be called with the value to be
validated and must return the converted value. If the value is
not valid, the validator must raise `ValidationFailed` with a
reason.
"""
def decorator(validator):
if not re.match(r'[a-zA-Z_][a-zA-Z0-9-]*$', option_name):
raise Exception(
'Option name must be a valid Python identifier, '
'got {0!r}'.format(option_name))
if option_name in self.fields:
raise Exception(
'Duplicate option name {0}'.format(option_name))
if hasattr(self.base_type, option_name):
raise Exception(
'Option name {0!r} reserved by base config class'.format(
option_name))
if option_name in ('parser', 'field_names'):
raise Exception('Reserved option name {0}'.format(option_name))
self.fields[option_name] = (
section_name, default_value, validator)
return validator
return decorator
def make_type(self, new_type_name):
type_dict = {}
field_names = []
for confopt, (confsec, default, validator) in self.fields.items():
prop = _make_config_property(confsec, confopt, default, validator)
name = prop.fget.__name__
type_dict[name] = prop
field_names.append(name)
type_dict['field_names'] = tuple(field_names)
return type(new_type_name, (self.base_type,), type_dict)
def _make_config_property(section_name, option_name, default_value, validator):
def getter(self):
try:
tovalidate = self.parser.get(section_name, option_name)
except NoOptionError:
if default_value is not None:
return default_value
else:
raise ValidationFailed(
'Missing required option {0!r} in section {1!r}'.format(
option_name, section_name))
return validator(tovalidate)
getter.__doc__ = validator.__doc__
getter.__name__ = option_name
def setter(self, value):
# TODO This doesn't work, we can't use the same naive validator
# in both directions. Perhaps the validator should accept two args,
# keyword args, or do we need two functions?
validated = validator(value)
if not self.parser.has_section(section_name):
self.parser.add_section(section_name)
self.parser.set(section_name, option_name, validated)
setter.__doc__ = validator.__doc__
setter.__name__ = option_name + '_setter'
return property(getter, setter)
import unittest
from StringIO import StringIO
from ConfigParser import RawConfigParser
from config_type_maker import (
ConfigurationTypeMaker, ValidationFailed, _make_config_property)
class ConfigurationTypeMakerTestCase(unittest.TestCase):
def test_reminder(self):
self.fail('Add tests here')
def test_constructor(self):
self.fail('not done')
ctm = ConfigurationTypeMaker()
def foovalidator_toupper(value):
"""
Validate that the value equals 'foo', and return it uppercased.
"""
if value != 'foo':
raise ValidationFailed("{0!r} is not equal to 'foo'".format(value))
return value.upper()
class MakeConfigPropertyTestCase(unittest.TestCase):
def setUp(self):
class ConfigStub(object):
def __init__(self, conftext):
self.parser = RawConfigParser()
self.parser.readfp(StringIO(conftext))
self.config_class = ConfigStub
def config_with_must_be_foo_property(self, default_value, config_text):
self.config_class.must_be_foo = _make_config_property(
'main', 'must_be_foo', default_value, foovalidator_toupper)
return self.config_class(config_text)
def test_getter_gets_validated_value(self):
cfg = self.config_with_must_be_foo_property(
None, '[main]\nmust_be_foo: foo\n')
self.assertEqual('FOO', cfg.must_be_foo)
def test_getter_raises_on_bad_value(self):
cfg = self.config_with_must_be_foo_property(
None, '[main]\nmust_be_foo: notfoo\n')
pattern = r"is not equal to 'foo'"
with self.assertRaisesRegexp(ValidationFailed, pattern):
cfg.must_be_foo
def test_getter_raises_on_missing_option(self):
cfg = self.config_with_must_be_foo_property(
None, '[main]\nignored: blah\n')
pattern = r"Missing required option 'must_be_foo' in section 'main'"
with self.assertRaisesRegexp(ValidationFailed, pattern):
cfg.must_be_foo
def test_getter_returns_default_on_missing_option(self):
cfg = self.config_with_must_be_foo_property(
'default value', '[main]\nignored: blah\n')
self.assertEqual('default value', cfg.must_be_foo)
def test_setter_sets_validated_value(self):
cfg = self.config_with_must_be_foo_property(
None, '[main]\nignored: blah\n')
cfg.must_be_foo = 'foo'
self.assertEqual('FOO', cfg.must_be_foo)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment