Implements configurables and options to create cleaner transformation classes, and ease documentation of available options.
This commit is contained in:
@ -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
|
||||
@ -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',
|
||||
|
||||
61
bonobo/util/options.py
Normal file
61
bonobo/util/options.py
Normal file
@ -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])
|
||||
76
tests/util/test_options.py
Normal file
76
tests/util/test_options.py
Normal file
@ -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
|
||||
Reference in New Issue
Block a user