diff --git a/bonobo/__init__.py b/bonobo/__init__.py index e82abba..05f5d14 100644 --- a/bonobo/__init__.py +++ b/bonobo/__init__.py @@ -32,6 +32,7 @@ __all__ = [ 'Bag', 'CsvReader', 'CsvWriter', + 'Configurable', 'FileReader', 'FileWriter', 'Graph', @@ -39,6 +40,7 @@ __all__ = [ 'JsonWriter', 'NOT_MODIFIED', 'NaiveStrategy', + 'Option', 'ProcessPoolExecutorStrategy', 'ThreadPoolExecutorStrategy', '__version__', @@ -53,3 +55,5 @@ __all__ = [ 'service', 'tee', ] + +del sys \ No newline at end of file diff --git a/bonobo/util/__init__.py b/bonobo/util/__init__.py index 9da50a9..eece5b9 100644 --- a/bonobo/util/__init__.py +++ b/bonobo/util/__init__.py @@ -7,8 +7,11 @@ import blessings from .helpers import run, console_run, jupyter_run from .tokens import NOT_MODIFIED +from .options import Configurable, Option __all__ = [ + 'Configurable', + 'Option', 'NOT_MODIFIED', 'console_run', 'jupyter_run', diff --git a/bonobo/util/options.py b/bonobo/util/options.py new file mode 100644 index 0000000..3618e63 --- /dev/null +++ b/bonobo/util/options.py @@ -0,0 +1,61 @@ +class Option: + def __init__(self, type=None, *, required=False, default=None): + self.name = None + self.type = type + self.required = required + self.default = default + + def __get__(self, inst, typ): + return inst.__options_values__.get(self.name, self.default) + + def __set__(self, inst, value): + inst.__options_values__[self.name] = self.type(value) if self.type else value + + +class ConfigurableMeta(type): + def __init__(cls, what, bases=None, dict=None): + super().__init__(what, bases, dict) + cls.__options__ = {} + for typ in cls.__mro__: + for name, value in typ.__dict__.items(): + if isinstance(value, Option): + if not value.name: + value.name = name + if not name in cls.__options__: + cls.__options__[name] = value + + +class Configurable(metaclass=ConfigurableMeta): + """ + Generic class for configurable objects. Configurable objects have a dictionary of "options" descriptors that defines + the configuration schema of the type. + + """ + + def __init__(self, **kwargs): + self.__options_values__ = {} + + missing = set() + for name, option in type(self).__options__.items(): + if option.required and not option.name in kwargs: + missing.add(name) + + if len(missing): + raise TypeError( + '{}() missing {} required option{}: {}.'.format( + type(self).__name__, + len(missing), 's' if len(missing) > 1 else '', ', '.join(map(repr, sorted(missing))) + ) + ) + + extraneous = set(kwargs.keys()) - set(type(self).__options__.keys()) + if len(extraneous): + raise TypeError( + '{}() got {} unexpected option{}: {}.'.format( + type(self).__name__, + len(extraneous), 's' if len(extraneous) > 1 else '', ', '.join(map(repr, sorted(extraneous))) + ) + ) + + for name, value in kwargs.items(): + setattr(self, name, kwargs[name]) diff --git a/tests/util/test_options.py b/tests/util/test_options.py new file mode 100644 index 0000000..45cc326 --- /dev/null +++ b/tests/util/test_options.py @@ -0,0 +1,76 @@ +import pytest +from bonobo import Configurable, Option + + +class MyConfigurable(Configurable): + required_str = Option(str, required=True) + default_str = Option(str, default='foo') + integer = Option(int) + + +class MyHarderConfigurable(MyConfigurable): + also_required = Option(bool, required=True) + + +class MyBetterConfigurable(MyConfigurable): + required_str = Option(str, required=False, default='kaboom') + + +def test_missing_required_option_error(): + with pytest.raises(TypeError) as exc: + MyConfigurable() + assert exc.match('missing 1 required option:') + + +def test_missing_required_options_error(): + with pytest.raises(TypeError) as exc: + MyHarderConfigurable() + assert exc.match('missing 2 required options:') + + +def test_extraneous_option_error(): + with pytest.raises(TypeError) as exc: + MyConfigurable(required_str='foo', hello='world') + assert exc.match('got 1 unexpected option:') + + +def test_extraneous_options_error(): + with pytest.raises(TypeError) as exc: + MyConfigurable(required_str='foo', hello='world', acme='corp') + assert exc.match('got 2 unexpected options:') + + +def test_defaults(): + o = MyConfigurable(required_str='hello') + assert o.required_str == 'hello' + assert o.default_str == 'foo' + assert o.integer == None + + +def test_str_type_factory(): + o = MyConfigurable(required_str=42) + assert o.required_str == '42' + assert o.default_str == 'foo' + assert o.integer == None + + +def test_int_type_factory(): + o = MyConfigurable(required_str='yo', default_str='bar', integer='42') + assert o.required_str == 'yo' + assert o.default_str == 'bar' + assert o.integer == 42 + + +def test_bool_type_factory(): + o = MyHarderConfigurable(required_str='yes', also_required='True') + assert o.required_str == 'yes' + assert o.default_str == 'foo' + assert o.integer == None + assert o.also_required == True + + +def test_option_resolution_order(): + o = MyBetterConfigurable() + assert o.required_str == 'kaboom' + assert o.default_str == 'foo' + assert o.integer == None