Merge branch 'dev_refactor_config' into develop
This commit is contained in:
@ -1,6 +1,6 @@
|
||||
from bonobo.structs import Bag, Graph, Token
|
||||
from bonobo.nodes import CsvReader, CsvWriter, FileReader, FileWriter, Filter, JsonReader, JsonWriter, Limit, \
|
||||
PrettyPrinter, PickleWriter, PickleReader, Tee, count, identity, noop, pprint
|
||||
PrettyPrinter, PickleWriter, PickleReader, RateLimited, Tee, count, identity, noop, pprint
|
||||
from bonobo.strategies import create_strategy
|
||||
from bonobo.util.objects import get_name
|
||||
|
||||
@ -104,6 +104,7 @@ register_api_group(
|
||||
PrettyPrinter,
|
||||
PickleReader,
|
||||
PickleWriter,
|
||||
RateLimited,
|
||||
Tee,
|
||||
count,
|
||||
identity,
|
||||
|
||||
@ -3,7 +3,7 @@ from bonobo.config.options import Method, Option
|
||||
from bonobo.config.processors import ContextProcessor
|
||||
from bonobo.config.services import Container, Exclusive, Service, requires
|
||||
|
||||
# bonobo.config public programming interface
|
||||
# Bonobo's Config API
|
||||
__all__ = [
|
||||
'Configurable',
|
||||
'Container',
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
from bonobo.config.options import Method, Option
|
||||
from bonobo.config.processors import ContextProcessor
|
||||
from bonobo.errors import ConfigurationError, AbstractError
|
||||
from bonobo.util.inspect import isoption, iscontextprocessor
|
||||
from bonobo.errors import AbstractError
|
||||
from bonobo.util.collections import sortedlist
|
||||
|
||||
__all__ = [
|
||||
'Configurable',
|
||||
'Option',
|
||||
]
|
||||
|
||||
get_creation_counter = lambda v: v._creation_counter
|
||||
|
||||
|
||||
class ConfigurableMeta(type):
|
||||
"""
|
||||
@ -15,36 +17,77 @@ class ConfigurableMeta(type):
|
||||
|
||||
def __init__(cls, what, bases=None, dict=None):
|
||||
super().__init__(what, bases, dict)
|
||||
cls.__options__ = {}
|
||||
cls.__positional_options__ = []
|
||||
cls.__processors__ = []
|
||||
cls.__wrappable__ = None
|
||||
|
||||
cls.__processors = sortedlist()
|
||||
cls.__methods = sortedlist()
|
||||
cls.__options = sortedlist()
|
||||
cls.__names = set()
|
||||
|
||||
# cls.__kwoptions = []
|
||||
|
||||
for typ in cls.__mro__:
|
||||
for name, value in typ.__dict__.items():
|
||||
if isinstance(value, Option):
|
||||
if isinstance(value, ContextProcessor):
|
||||
cls.__processors__.append(value)
|
||||
else:
|
||||
if not value.name:
|
||||
value.name = name
|
||||
for name, value in filter(lambda x: isoption(x[1]), typ.__dict__.items()):
|
||||
if iscontextprocessor(value):
|
||||
cls.__processors.insort((value._creation_counter, value))
|
||||
continue
|
||||
|
||||
if isinstance(value, Method):
|
||||
if cls.__wrappable__:
|
||||
raise ConfigurationError(
|
||||
'Cannot define more than one "Method" option in a configurable. That may change in the future.'
|
||||
)
|
||||
cls.__wrappable__ = name
|
||||
if not value.name:
|
||||
value.name = name
|
||||
|
||||
if not name in cls.__options__:
|
||||
cls.__options__[name] = value
|
||||
if not name in cls.__names:
|
||||
cls.__names.add(name)
|
||||
cls.__options.insort((not value.positional, value._creation_counter, name, value))
|
||||
|
||||
if value.positional:
|
||||
cls.__positional_options__.append(name)
|
||||
@property
|
||||
def __options__(cls):
|
||||
return ((name, option) for _, _, name, option in cls.__options)
|
||||
|
||||
# This can be done before, more efficiently. Not so bad neither as this is only done at type() creation time
|
||||
# (aka class Xxx(...) time) and there should not be hundreds of processors. Still not very elegant.
|
||||
cls.__processors__ = sorted(cls.__processors__, key=lambda v: v._creation_counter)
|
||||
@property
|
||||
def __options_dict__(cls):
|
||||
return dict(cls.__options__)
|
||||
|
||||
@property
|
||||
def __processors__(cls):
|
||||
return (processor for _, processor in cls.__processors)
|
||||
|
||||
def __repr__(self):
|
||||
return ' '.join(('<Configurable', super(ConfigurableMeta, self).__repr__().split(' ', 1)[1],))
|
||||
|
||||
|
||||
try:
|
||||
import _functools
|
||||
except:
|
||||
import functools
|
||||
|
||||
PartiallyConfigured = functools.partial
|
||||
else:
|
||||
class PartiallyConfigured(_functools.partial):
|
||||
@property # TODO XXX cache this shit
|
||||
def _options_values(self):
|
||||
""" Simulate option values for partially configured objects. """
|
||||
try:
|
||||
return self.__options_values
|
||||
except AttributeError:
|
||||
self.__options_values = {**self.keywords}
|
||||
|
||||
position = 0
|
||||
|
||||
for name, option in self.func.__options__:
|
||||
if not option.positional:
|
||||
break # no positional left
|
||||
if name in self.keywords:
|
||||
continue # already fulfilled
|
||||
|
||||
self.__options_values[name] = self.args[position] if len(self.args) >= position + 1 else None
|
||||
position += 1
|
||||
|
||||
return self.__options_values
|
||||
|
||||
def __getattr__(self, item):
|
||||
_dict = self.func.__options_dict__
|
||||
if item in _dict:
|
||||
return _dict[item].__get__(self, self.func)
|
||||
return getattr(self.func, item)
|
||||
|
||||
|
||||
class Configurable(metaclass=ConfigurableMeta):
|
||||
@ -54,61 +97,108 @@ class Configurable(metaclass=ConfigurableMeta):
|
||||
|
||||
"""
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
if cls.__wrappable__ and len(args) == 1 and hasattr(args[0], '__call__'):
|
||||
return type(args[0].__name__, (cls, ), {cls.__wrappable__: args[0]})
|
||||
def __new__(cls, *args, _final=False, **kwargs):
|
||||
"""
|
||||
Custom instance builder. If not all options are fulfilled, will return a :class:`PartiallyConfigured` instance
|
||||
which is just a :class:`functools.partial` object that behaves like a :class:`Configurable` instance.
|
||||
|
||||
return super(Configurable, cls).__new__(cls)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__()
|
||||
|
||||
# initialize option's value dictionary, used by descriptor implementation (see Option).
|
||||
self.__options_values__ = {}
|
||||
The special `_final` argument can be used to force final instance to be created, or an error raised if options
|
||||
are missing.
|
||||
|
||||
:param args:
|
||||
:param _final: bool
|
||||
:param kwargs:
|
||||
:return: Configurable or PartiallyConfigured
|
||||
"""
|
||||
options = tuple(cls.__options__)
|
||||
# compute missing options, given the kwargs.
|
||||
missing = set()
|
||||
for name, option in type(self).__options__.items():
|
||||
for name, option in options:
|
||||
if option.required and not option.name in kwargs:
|
||||
missing.add(name)
|
||||
|
||||
# transform positional arguments in keyword arguments if possible.
|
||||
position = 0
|
||||
for positional_option in self.__positional_options__:
|
||||
if len(args) <= position:
|
||||
break
|
||||
kwargs[positional_option] = args[position]
|
||||
position += 1
|
||||
if positional_option in missing:
|
||||
missing.remove(positional_option)
|
||||
for name, option in options:
|
||||
if not option.positional:
|
||||
break # option orders make all positional options first, job done.
|
||||
|
||||
# complain if there are still missing options.
|
||||
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)))
|
||||
)
|
||||
)
|
||||
if not isoption(getattr(cls, name)):
|
||||
missing.remove(name)
|
||||
continue
|
||||
|
||||
if len(args) <= position:
|
||||
break # no more positional arguments given.
|
||||
|
||||
position += 1
|
||||
if name in missing:
|
||||
missing.remove(name)
|
||||
|
||||
# complain if there is more options than possible.
|
||||
extraneous = set(kwargs.keys()) - set(type(self).__options__.keys())
|
||||
extraneous = set(kwargs.keys()) - (set(next(zip(*options))) if len(options) else set())
|
||||
if len(extraneous):
|
||||
raise TypeError(
|
||||
'{}() got {} unexpected option{}: {}.'.format(
|
||||
type(self).__name__,
|
||||
cls.__name__,
|
||||
len(extraneous), 's' if len(extraneous) > 1 else '', ', '.join(map(repr, sorted(extraneous)))
|
||||
)
|
||||
)
|
||||
|
||||
# missing options? we'll return a partial instance to finish the work later, unless we're required to be
|
||||
# "final".
|
||||
if len(missing):
|
||||
if _final:
|
||||
raise TypeError(
|
||||
'{}() missing {} required option{}: {}.'.format(
|
||||
cls.__name__,
|
||||
len(missing), 's' if len(missing) > 1 else '', ', '.join(map(repr, sorted(missing)))
|
||||
)
|
||||
)
|
||||
return PartiallyConfigured(cls, *args, **kwargs)
|
||||
|
||||
return super(Configurable, cls).__new__(cls)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
# initialize option's value dictionary, used by descriptor implementation (see Option).
|
||||
self._options_values = {
|
||||
**kwargs
|
||||
}
|
||||
|
||||
# set option values.
|
||||
for name, value in kwargs.items():
|
||||
setattr(self, name, value)
|
||||
|
||||
position = 0
|
||||
for name, option in self.__options__:
|
||||
if not option.positional:
|
||||
break # option orders make all positional options first
|
||||
|
||||
# value was overriden? Skip.
|
||||
maybe_value = getattr(type(self), name)
|
||||
if not isoption(maybe_value):
|
||||
continue
|
||||
|
||||
if len(args) <= position:
|
||||
break
|
||||
|
||||
if name in self._options_values:
|
||||
raise ValueError('Already got a value for option {}'.format(name))
|
||||
|
||||
setattr(self, name, args[position])
|
||||
position += 1
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
""" You can implement a configurable callable behaviour by implemenenting the call(...) method. Of course, it is also backward compatible with legacy __call__ override.
|
||||
"""
|
||||
return self.call(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def __options__(self):
|
||||
return type(self).__options__
|
||||
|
||||
@property
|
||||
def __processors__(self):
|
||||
return type(self).__processors__
|
||||
|
||||
def call(self, *args, **kwargs):
|
||||
raise AbstractError('Not implemented.')
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
from bonobo.util.inspect import istype
|
||||
|
||||
|
||||
class Option:
|
||||
"""
|
||||
An Option is a descriptor for Configurable's parameters.
|
||||
@ -14,7 +17,9 @@ class Option:
|
||||
If an option is required, an error will be raised if no value is provided (at runtime). If it is not, option
|
||||
will have the default value if user does not override it at runtime.
|
||||
|
||||
(default: False)
|
||||
Ignored if a default is provided, meaning that the option cannot be required.
|
||||
|
||||
(default: True)
|
||||
|
||||
.. attribute:: positional
|
||||
|
||||
@ -48,10 +53,10 @@ class Option:
|
||||
|
||||
_creation_counter = 0
|
||||
|
||||
def __init__(self, type=None, *, required=False, positional=False, default=None):
|
||||
def __init__(self, type=None, *, required=True, positional=False, default=None):
|
||||
self.name = None
|
||||
self.type = type
|
||||
self.required = required
|
||||
self.required = required if default is None else False
|
||||
self.positional = positional
|
||||
self.default = default
|
||||
|
||||
@ -60,12 +65,27 @@ class Option:
|
||||
Option._creation_counter += 1
|
||||
|
||||
def __get__(self, inst, typ):
|
||||
if not self.name in inst.__options_values__:
|
||||
inst.__options_values__[self.name] = self.get_default()
|
||||
return inst.__options_values__[self.name]
|
||||
# XXX If we call this on the type, then either return overriden value or ... ???
|
||||
if inst is None:
|
||||
return vars(type).get(self.name, self)
|
||||
|
||||
if not self.name in inst._options_values:
|
||||
inst._options_values[self.name] = self.get_default()
|
||||
|
||||
return inst._options_values[self.name]
|
||||
|
||||
def __set__(self, inst, value):
|
||||
inst.__options_values__[self.name] = self.clean(value)
|
||||
inst._options_values[self.name] = self.clean(value)
|
||||
|
||||
def __repr__(self):
|
||||
return '<{positional}{typename} {type}{name} default={default!r}{required}>'.format(
|
||||
typename=type(self).__name__,
|
||||
type='({})'.format(self.type) if istype(self.type) else '',
|
||||
name=self.name,
|
||||
positional='*' if self.positional else '**',
|
||||
default=self.default,
|
||||
required=' (required)' if self.required else '',
|
||||
)
|
||||
|
||||
def clean(self, value):
|
||||
return self.type(value) if self.type else value
|
||||
@ -105,20 +125,18 @@ class Method(Option):
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(None, required=False)
|
||||
|
||||
def __get__(self, inst, typ):
|
||||
if not self.name in inst.__options_values__:
|
||||
inst.__options_values__[self.name] = getattr(inst, self.name)
|
||||
return inst.__options_values__[self.name]
|
||||
def __init__(self, *, required=True, positional=True):
|
||||
super().__init__(None, required=required, positional=positional)
|
||||
|
||||
def __set__(self, inst, value):
|
||||
if isinstance(value, str):
|
||||
raise ValueError('should be callable')
|
||||
inst.__options_values__[self.name] = self.type(value) if self.type else value
|
||||
|
||||
def clean(self, value):
|
||||
if not hasattr(value, '__call__'):
|
||||
raise ValueError('{} value must be callable.'.format(type(self).__name__))
|
||||
return value
|
||||
raise TypeError('Option of type {!r} is expecting a callable value, got {!r} object (which is not).'.format(
|
||||
type(self).__name__, type(value).__name__))
|
||||
inst._options_values[self.name] = self.type(value) if self.type else value
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
# only here to trick IDEs into thinking this is callable.
|
||||
raise NotImplementedError('You cannot call the descriptor')
|
||||
|
||||
|
||||
|
||||
|
||||
@ -74,8 +74,7 @@ class ContextCurrifier:
|
||||
def __init__(self, wrapped, *initial_context):
|
||||
self.wrapped = wrapped
|
||||
self.context = tuple(initial_context)
|
||||
self._stack = []
|
||||
self._stack_values = []
|
||||
self._stack, self._stack_values = None, None
|
||||
|
||||
def __iter__(self):
|
||||
yield from self.wrapped
|
||||
@ -86,8 +85,10 @@ class ContextCurrifier:
|
||||
return self.wrapped(*self.context, *args, **kwargs)
|
||||
|
||||
def setup(self, *context):
|
||||
if len(self._stack):
|
||||
if self._stack is not None:
|
||||
raise RuntimeError('Cannot setup context currification twice.')
|
||||
|
||||
self._stack, self._stack_values = list(), list()
|
||||
for processor in resolve_processors(self.wrapped):
|
||||
_processed = processor(self.wrapped, *context, *self.context)
|
||||
_append_to_context = next(_processed)
|
||||
@ -97,7 +98,7 @@ class ContextCurrifier:
|
||||
self._stack.append(_processed)
|
||||
|
||||
def teardown(self):
|
||||
while len(self._stack):
|
||||
while self._stack:
|
||||
processor = self._stack.pop()
|
||||
try:
|
||||
# todo yield from ? how to ?
|
||||
@ -108,6 +109,7 @@ class ContextCurrifier:
|
||||
else:
|
||||
# No error ? We should have had StopIteration ...
|
||||
raise RuntimeError('Context processors should not yield more than once.')
|
||||
self._stack, self._stack_values = None, None
|
||||
|
||||
@contextmanager
|
||||
def as_contextmanager(self, *context):
|
||||
|
||||
@ -53,7 +53,7 @@ class Service(Option):
|
||||
super().__init__(str, required=False, default=name)
|
||||
|
||||
def __set__(self, inst, value):
|
||||
inst.__options_values__[self.name] = validate_service_name(value)
|
||||
inst._options_values[self.name] = validate_service_name(value)
|
||||
|
||||
def resolve(self, inst, services):
|
||||
try:
|
||||
@ -75,7 +75,7 @@ class Container(dict):
|
||||
|
||||
def args_for(self, mixed):
|
||||
try:
|
||||
options = mixed.__options__
|
||||
options = dict(mixed.__options__)
|
||||
except AttributeError:
|
||||
options = {}
|
||||
|
||||
|
||||
@ -13,13 +13,13 @@ def path_str(path):
|
||||
|
||||
|
||||
class OpenDataSoftAPI(Configurable):
|
||||
dataset = Option(str, required=True)
|
||||
dataset = Option(str, positional=True)
|
||||
endpoint = Option(str, default='{scheme}://{netloc}{path}')
|
||||
scheme = Option(str, default='https')
|
||||
netloc = Option(str, default='data.opendatasoft.com')
|
||||
path = Option(path_str, default='/api/records/1.0/search/')
|
||||
rows = Option(int, default=500)
|
||||
limit = Option(int, default=None)
|
||||
limit = Option(int, required=False)
|
||||
timezone = Option(str, default='Europe/Paris')
|
||||
kwargs = Option(dict, default=dict)
|
||||
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
from bonobo.nodes.io import __all__ as _all_io
|
||||
from bonobo.nodes.io import *
|
||||
|
||||
from bonobo.nodes.basics import __all__ as _all_basics
|
||||
from bonobo.nodes.basics import *
|
||||
|
||||
from bonobo.nodes.basics import __all__ as _all_basics
|
||||
from bonobo.nodes.filter import Filter
|
||||
from bonobo.nodes.io import *
|
||||
from bonobo.nodes.io import __all__ as _all_io
|
||||
from bonobo.nodes.throttle import RateLimited
|
||||
|
||||
__all__ = _all_basics + _all_io + ['Filter']
|
||||
__all__ = _all_basics + _all_io + ['Filter', 'RateLimited']
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import functools
|
||||
from pprint import pprint as _pprint
|
||||
|
||||
import itertools
|
||||
|
||||
from colorama import Fore, Style
|
||||
|
||||
from bonobo import settings
|
||||
from bonobo.config import Configurable, Option
|
||||
from bonobo.config.processors import ContextProcessor
|
||||
from bonobo.constants import NOT_MODIFIED
|
||||
from bonobo.structs.bags import Bag
|
||||
from bonobo.util.compat import deprecated
|
||||
from bonobo.util.objects import ValueHolder
|
||||
from bonobo.util.term import CLEAR_EOL
|
||||
from bonobo.constants import NOT_MODIFIED
|
||||
|
||||
__all__ = [
|
||||
'identity',
|
||||
@ -87,8 +87,12 @@ class PrettyPrinter(Configurable):
|
||||
)
|
||||
|
||||
|
||||
pprint = PrettyPrinter()
|
||||
pprint.__name__ = 'pprint'
|
||||
_pprint = PrettyPrinter()
|
||||
|
||||
|
||||
@deprecated
|
||||
def pprint(*args, **kwargs):
|
||||
return _pprint(*args, **kwargs)
|
||||
|
||||
|
||||
def PrettyPrint(title_keys=('title', 'name', 'id'), print_values=True, sort=True):
|
||||
|
||||
@ -26,7 +26,7 @@ class CsvHandler(FileHandler):
|
||||
"""
|
||||
delimiter = Option(str, default=';')
|
||||
quotechar = Option(str, default='"')
|
||||
headers = Option(tuple)
|
||||
headers = Option(tuple, required=False)
|
||||
|
||||
|
||||
class CsvReader(IOFormatEnabled, FileReader, CsvHandler):
|
||||
|
||||
@ -17,7 +17,7 @@ class PickleHandler(FileHandler):
|
||||
|
||||
"""
|
||||
|
||||
item_names = Option(tuple)
|
||||
item_names = Option(tuple, required=False)
|
||||
|
||||
|
||||
class PickleReader(IOFormatEnabled, FileReader, PickleHandler):
|
||||
|
||||
55
bonobo/nodes/throttle.py
Normal file
55
bonobo/nodes/throttle.py
Normal file
@ -0,0 +1,55 @@
|
||||
import threading
|
||||
import time
|
||||
|
||||
from bonobo.config import Configurable, ContextProcessor, Method, Option
|
||||
|
||||
|
||||
class RateLimitBucket(threading.Thread):
|
||||
daemon = True
|
||||
|
||||
@property
|
||||
def stopped(self):
|
||||
return self._stop_event.is_set()
|
||||
|
||||
def __init__(self, initial=1, period=1, amount=1):
|
||||
super(RateLimitBucket, self).__init__()
|
||||
self.semaphore = threading.BoundedSemaphore(initial)
|
||||
self.amount = amount
|
||||
self.period = period
|
||||
|
||||
self._stop_event = threading.Event()
|
||||
|
||||
def stop(self):
|
||||
self._stop_event.set()
|
||||
|
||||
def run(self):
|
||||
while not self.stopped:
|
||||
time.sleep(self.period)
|
||||
for _ in range(self.amount):
|
||||
self.semaphore.release()
|
||||
|
||||
def wait(self):
|
||||
return self.semaphore.acquire()
|
||||
|
||||
|
||||
class RateLimited(Configurable):
|
||||
handler = Method()
|
||||
|
||||
initial = Option(int, positional=True, default=1)
|
||||
period = Option(int, positional=True, default=1)
|
||||
amount = Option(int, positional=True, default=1)
|
||||
|
||||
@ContextProcessor
|
||||
def bucket(self, context):
|
||||
print(context)
|
||||
bucket = RateLimitBucket(self.initial, self.amount, self.period)
|
||||
bucket.start()
|
||||
print(bucket)
|
||||
yield bucket
|
||||
bucket.stop()
|
||||
bucket.join()
|
||||
|
||||
def call(self, bucket, *args, **kwargs):
|
||||
print(bucket, args, kwargs)
|
||||
bucket.wait()
|
||||
return self.handler(*args, **kwargs)
|
||||
@ -27,7 +27,7 @@ class Setting:
|
||||
self.validator = None
|
||||
|
||||
def __repr__(self):
|
||||
return '<Setting {}={!r}>'.format(self.name, self.value)
|
||||
return '<Setting {}={!r}>'.format(self.name, self.get())
|
||||
|
||||
def set(self, value):
|
||||
if self.validator and not self.validator(value):
|
||||
|
||||
6
bonobo/util/collections.py
Normal file
6
bonobo/util/collections.py
Normal file
@ -0,0 +1,6 @@
|
||||
import bisect
|
||||
|
||||
|
||||
class sortedlist(list):
|
||||
def insort(self, x):
|
||||
bisect.insort(self, x)
|
||||
114
bonobo/util/inspect.py
Normal file
114
bonobo/util/inspect.py
Normal file
@ -0,0 +1,114 @@
|
||||
from collections import namedtuple
|
||||
|
||||
|
||||
def isconfigurabletype(mixed):
|
||||
"""
|
||||
Check if the given argument is an instance of :class:`bonobo.config.ConfigurableMeta`, meaning it has all the
|
||||
plumbery necessary to build :class:`bonobo.config.Configurable`-like instances.
|
||||
|
||||
:param mixed:
|
||||
:return: bool
|
||||
"""
|
||||
from bonobo.config.configurables import ConfigurableMeta
|
||||
return isinstance(mixed, ConfigurableMeta)
|
||||
|
||||
|
||||
def isconfigurable(mixed):
|
||||
"""
|
||||
Check if the given argument is an instance of :class:`bonobo.config.Configurable`.
|
||||
|
||||
:param mixed:
|
||||
:return: bool
|
||||
"""
|
||||
from bonobo.config.configurables import Configurable
|
||||
return isinstance(mixed, Configurable)
|
||||
|
||||
|
||||
def isoption(mixed):
|
||||
"""
|
||||
Check if the given argument is an instance of :class:`bonobo.config.Option`.
|
||||
|
||||
:param mixed:
|
||||
:return: bool
|
||||
"""
|
||||
|
||||
from bonobo.config.options import Option
|
||||
return isinstance(mixed, Option)
|
||||
|
||||
|
||||
def ismethod(mixed):
|
||||
"""
|
||||
Check if the given argument is an instance of :class:`bonobo.config.Method`.
|
||||
|
||||
:param mixed:
|
||||
:return: bool
|
||||
"""
|
||||
from bonobo.config.options import Method
|
||||
return isinstance(mixed, Method)
|
||||
|
||||
|
||||
def iscontextprocessor(x):
|
||||
"""
|
||||
Check if the given argument is an instance of :class:`bonobo.config.ContextProcessor`.
|
||||
|
||||
:param mixed:
|
||||
:return: bool
|
||||
"""
|
||||
from bonobo.config.processors import ContextProcessor
|
||||
return isinstance(x, ContextProcessor)
|
||||
|
||||
|
||||
def istype(mixed):
|
||||
"""
|
||||
Check if the given argument is a type object.
|
||||
|
||||
:param mixed:
|
||||
:return: bool
|
||||
"""
|
||||
return isinstance(mixed, type)
|
||||
|
||||
|
||||
ConfigurableInspection = namedtuple('ConfigurableInspection',
|
||||
[
|
||||
'type',
|
||||
'instance',
|
||||
'options',
|
||||
'processors',
|
||||
'partial',
|
||||
])
|
||||
|
||||
ConfigurableInspection.__enter__ = lambda self: self
|
||||
ConfigurableInspection.__exit__ = lambda *exc_details: None
|
||||
|
||||
|
||||
def inspect_node(mixed, *, _partial=None):
|
||||
"""
|
||||
If the given argument is somehow a :class:`bonobo.config.Configurable` object (either a subclass, an instance, or
|
||||
a partially configured instance), then it will return a :class:`ConfigurableInspection` namedtuple, used to inspect
|
||||
the configurable metadata (options). If you want to get the option values, you don't need this, it is only usefull
|
||||
to perform introspection on a configurable.
|
||||
|
||||
If it's not looking like a configurable, it will raise a :class:`TypeError`.
|
||||
|
||||
:param mixed:
|
||||
:return: ConfigurableInspection
|
||||
|
||||
:raise: TypeError
|
||||
"""
|
||||
if isconfigurabletype(mixed):
|
||||
inst, typ = None, mixed
|
||||
elif isconfigurable(mixed):
|
||||
inst, typ = mixed, type(mixed)
|
||||
elif hasattr(mixed, 'func'):
|
||||
return inspect_node(mixed.func, _partial=(mixed.args, mixed.keywords))
|
||||
else:
|
||||
raise TypeError(
|
||||
'Not a Configurable, nor a Configurable instance and not even a partially configured Configurable. Check your inputs.')
|
||||
|
||||
return ConfigurableInspection(
|
||||
typ,
|
||||
inst,
|
||||
list(typ.__options__),
|
||||
list(typ.__processors__),
|
||||
_partial,
|
||||
)
|
||||
@ -2,12 +2,17 @@ import pytest
|
||||
|
||||
from bonobo.config.configurables import Configurable
|
||||
from bonobo.config.options import Option
|
||||
from bonobo.util.inspect import inspect_node
|
||||
|
||||
|
||||
class NoOptConfigurable(Configurable):
|
||||
pass
|
||||
|
||||
|
||||
class MyConfigurable(Configurable):
|
||||
required_str = Option(str, required=True)
|
||||
required_str = Option(str)
|
||||
default_str = Option(str, default='foo')
|
||||
integer = Option(int)
|
||||
integer = Option(int, required=False)
|
||||
|
||||
|
||||
class MyHarderConfigurable(MyConfigurable):
|
||||
@ -25,14 +30,20 @@ class MyConfigurableUsingPositionalOptions(MyConfigurable):
|
||||
|
||||
|
||||
def test_missing_required_option_error():
|
||||
with inspect_node(MyConfigurable()) as ni:
|
||||
assert ni.partial
|
||||
|
||||
with pytest.raises(TypeError) as exc:
|
||||
MyConfigurable()
|
||||
MyConfigurable(_final=True)
|
||||
assert exc.match('missing 1 required option:')
|
||||
|
||||
|
||||
def test_missing_required_options_error():
|
||||
with inspect_node(MyHarderConfigurable()) as ni:
|
||||
assert ni.partial
|
||||
|
||||
with pytest.raises(TypeError) as exc:
|
||||
MyHarderConfigurable()
|
||||
MyHarderConfigurable(_final=True)
|
||||
assert exc.match('missing 2 required options:')
|
||||
|
||||
|
||||
@ -50,6 +61,10 @@ def test_extraneous_options_error():
|
||||
|
||||
def test_defaults():
|
||||
o = MyConfigurable(required_str='hello')
|
||||
|
||||
with inspect_node(o) as ni:
|
||||
assert not ni.partial
|
||||
|
||||
assert o.required_str == 'hello'
|
||||
assert o.default_str == 'foo'
|
||||
assert o.integer == None
|
||||
@ -57,6 +72,10 @@ def test_defaults():
|
||||
|
||||
def test_str_type_factory():
|
||||
o = MyConfigurable(required_str=42)
|
||||
|
||||
with inspect_node(o) as ni:
|
||||
assert not ni.partial
|
||||
|
||||
assert o.required_str == '42'
|
||||
assert o.default_str == 'foo'
|
||||
assert o.integer == None
|
||||
@ -64,6 +83,10 @@ def test_str_type_factory():
|
||||
|
||||
def test_int_type_factory():
|
||||
o = MyConfigurable(required_str='yo', default_str='bar', integer='42')
|
||||
|
||||
with inspect_node(o) as ni:
|
||||
assert not ni.partial
|
||||
|
||||
assert o.required_str == 'yo'
|
||||
assert o.default_str == 'bar'
|
||||
assert o.integer == 42
|
||||
@ -71,6 +94,10 @@ def test_int_type_factory():
|
||||
|
||||
def test_bool_type_factory():
|
||||
o = MyHarderConfigurable(required_str='yes', also_required='True')
|
||||
|
||||
with inspect_node(o) as ni:
|
||||
assert not ni.partial
|
||||
|
||||
assert o.required_str == 'yes'
|
||||
assert o.default_str == 'foo'
|
||||
assert o.integer == None
|
||||
@ -79,6 +106,10 @@ def test_bool_type_factory():
|
||||
|
||||
def test_option_resolution_order():
|
||||
o = MyBetterConfigurable()
|
||||
|
||||
with inspect_node(o) as ni:
|
||||
assert not ni.partial
|
||||
|
||||
assert o.required_str == 'kaboom'
|
||||
assert o.default_str == 'foo'
|
||||
assert o.integer == None
|
||||
@ -86,3 +117,21 @@ def test_option_resolution_order():
|
||||
|
||||
def test_option_positional():
|
||||
o = MyConfigurableUsingPositionalOptions('1', '2', '3', required_str='hello')
|
||||
|
||||
with inspect_node(o) as ni:
|
||||
assert not ni.partial
|
||||
|
||||
assert o.first == '1'
|
||||
assert o.second == '2'
|
||||
assert o.third == '3'
|
||||
assert o.required_str == 'hello'
|
||||
assert o.default_str == 'foo'
|
||||
assert o.integer is None
|
||||
|
||||
|
||||
def test_no_opt_configurable():
|
||||
o = NoOptConfigurable()
|
||||
|
||||
with inspect_node(o) as ni:
|
||||
assert not ni.partial
|
||||
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import pytest
|
||||
|
||||
from bonobo.config import Configurable, Method, Option
|
||||
from bonobo.errors import ConfigurationError
|
||||
from bonobo.util.inspect import inspect_node
|
||||
|
||||
|
||||
class MethodBasedConfigurable(Configurable):
|
||||
@ -13,22 +11,56 @@ class MethodBasedConfigurable(Configurable):
|
||||
self.handler(*args, **kwargs)
|
||||
|
||||
|
||||
def test_one_wrapper_only():
|
||||
with pytest.raises(ConfigurationError):
|
||||
def test_multiple_wrapper_suppored():
|
||||
class TwoMethods(Configurable):
|
||||
h1 = Method(required=True)
|
||||
h2 = Method(required=True)
|
||||
|
||||
class TwoMethods(Configurable):
|
||||
h1 = Method()
|
||||
h2 = Method()
|
||||
with inspect_node(TwoMethods) as ci:
|
||||
assert ci.type == TwoMethods
|
||||
assert not ci.instance
|
||||
assert len(ci.options) == 2
|
||||
assert not len(ci.processors)
|
||||
assert not ci.partial
|
||||
|
||||
@TwoMethods
|
||||
def OneMethod():
|
||||
pass
|
||||
|
||||
with inspect_node(OneMethod) as ci:
|
||||
assert ci.type == TwoMethods
|
||||
assert not ci.instance
|
||||
assert len(ci.options) == 2
|
||||
assert not len(ci.processors)
|
||||
assert ci.partial
|
||||
|
||||
@OneMethod
|
||||
def transformation():
|
||||
pass
|
||||
|
||||
with inspect_node(transformation) as ci:
|
||||
assert ci.type == TwoMethods
|
||||
assert ci.instance
|
||||
assert len(ci.options) == 2
|
||||
assert not len(ci.processors)
|
||||
assert not ci.partial
|
||||
|
||||
|
||||
def test_define_with_decorator():
|
||||
calls = []
|
||||
|
||||
@MethodBasedConfigurable
|
||||
def Concrete(self, *args, **kwargs):
|
||||
calls.append((args, kwargs, ))
|
||||
def my_handler(*args, **kwargs):
|
||||
calls.append((args, kwargs,))
|
||||
|
||||
Concrete = MethodBasedConfigurable(my_handler)
|
||||
|
||||
assert callable(Concrete.handler)
|
||||
assert Concrete.handler == my_handler
|
||||
|
||||
with inspect_node(Concrete) as ci:
|
||||
assert ci.type == MethodBasedConfigurable
|
||||
assert ci.partial
|
||||
|
||||
t = Concrete('foo', bar='baz')
|
||||
|
||||
assert callable(t.handler)
|
||||
@ -37,13 +69,29 @@ def test_define_with_decorator():
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_late_binding_method_decoration():
|
||||
calls = []
|
||||
|
||||
@MethodBasedConfigurable(foo='foo')
|
||||
def Concrete(*args, **kwargs):
|
||||
calls.append((args, kwargs,))
|
||||
|
||||
assert callable(Concrete.handler)
|
||||
t = Concrete(bar='baz')
|
||||
|
||||
assert callable(t.handler)
|
||||
assert len(calls) == 0
|
||||
t()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
def test_define_with_argument():
|
||||
calls = []
|
||||
|
||||
def concrete_handler(*args, **kwargs):
|
||||
calls.append((args, kwargs, ))
|
||||
calls.append((args, kwargs,))
|
||||
|
||||
t = MethodBasedConfigurable('foo', bar='baz', handler=concrete_handler)
|
||||
t = MethodBasedConfigurable(concrete_handler, 'foo', bar='baz')
|
||||
assert callable(t.handler)
|
||||
assert len(calls) == 0
|
||||
t()
|
||||
@ -55,7 +103,7 @@ def test_define_with_inheritance():
|
||||
|
||||
class Inheriting(MethodBasedConfigurable):
|
||||
def handler(self, *args, **kwargs):
|
||||
calls.append((args, kwargs, ))
|
||||
calls.append((args, kwargs,))
|
||||
|
||||
t = Inheriting('foo', bar='baz')
|
||||
assert callable(t.handler)
|
||||
@ -71,8 +119,8 @@ def test_inheritance_then_decorate():
|
||||
pass
|
||||
|
||||
@Inheriting
|
||||
def Concrete(self, *args, **kwargs):
|
||||
calls.append((args, kwargs, ))
|
||||
def Concrete(*args, **kwargs):
|
||||
calls.append((args, kwargs,))
|
||||
|
||||
assert callable(Concrete.handler)
|
||||
t = Concrete('foo', bar='baz')
|
||||
|
||||
66
tests/config/test_methods_partial.py
Normal file
66
tests/config/test_methods_partial.py
Normal file
@ -0,0 +1,66 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bonobo.config import Configurable, ContextProcessor, Method, Option
|
||||
from bonobo.util.inspect import inspect_node
|
||||
|
||||
|
||||
class Bobby(Configurable):
|
||||
handler = Method()
|
||||
handler2 = Method()
|
||||
foo = Option(positional=True)
|
||||
bar = Option(required=False)
|
||||
|
||||
@ContextProcessor
|
||||
def think(self, context):
|
||||
yield 'different'
|
||||
|
||||
def call(self, think, *args, **kwargs):
|
||||
self.handler('1', *args, **kwargs)
|
||||
self.handler2('2', *args, **kwargs)
|
||||
|
||||
|
||||
def test_partial():
|
||||
C = Bobby
|
||||
|
||||
# inspect the configurable class
|
||||
with inspect_node(C) as ci:
|
||||
assert ci.type == Bobby
|
||||
assert not ci.instance
|
||||
assert len(ci.options) == 4
|
||||
assert len(ci.processors) == 1
|
||||
assert not ci.partial
|
||||
|
||||
# instanciate a partial instance ...
|
||||
f1 = MagicMock()
|
||||
C = C(f1)
|
||||
|
||||
with inspect_node(C) as ci:
|
||||
assert ci.type == Bobby
|
||||
assert not ci.instance
|
||||
assert len(ci.options) == 4
|
||||
assert len(ci.processors) == 1
|
||||
assert ci.partial
|
||||
assert ci.partial[0] == (f1,)
|
||||
assert not len(ci.partial[1])
|
||||
|
||||
# instanciate a more complete partial instance ...
|
||||
f2 = MagicMock()
|
||||
C = C(f2)
|
||||
|
||||
with inspect_node(C) as ci:
|
||||
assert ci.type == Bobby
|
||||
assert not ci.instance
|
||||
assert len(ci.options) == 4
|
||||
assert len(ci.processors) == 1
|
||||
assert ci.partial
|
||||
assert ci.partial[0] == (f1, f2,)
|
||||
assert not len(ci.partial[1])
|
||||
|
||||
c = C('foo')
|
||||
|
||||
with inspect_node(c) as ci:
|
||||
assert ci.type == Bobby
|
||||
assert ci.instance
|
||||
assert len(ci.options) == 4
|
||||
assert len(ci.processors) == 1
|
||||
assert not ci.partial
|
||||
@ -5,6 +5,7 @@ import pytest
|
||||
import bonobo
|
||||
from bonobo.config.processors import ContextCurrifier
|
||||
from bonobo.constants import NOT_MODIFIED
|
||||
from bonobo.util.inspect import inspect_node
|
||||
|
||||
|
||||
def test_count():
|
||||
|
||||
Reference in New Issue
Block a user