Merge pull request #198 from hartym/develop

Development of 0.6
This commit is contained in:
Romain Dorgueil
2017-10-23 22:38:04 +02:00
committed by GitHub
39 changed files with 993 additions and 689 deletions

View File

@ -1,5 +1,5 @@
# This file has been auto-generated by Medikit. All changes will be lost. # Generated by Medikit 0.4a5 on 2017-10-22.
# Updated on 2017-10-21. # All changes will be overriden.
PACKAGE ?= bonobo PACKAGE ?= bonobo
PYTHON ?= $(shell which python) PYTHON ?= $(shell which python)

View File

@ -1,105 +1,34 @@
import mimetypes
import os
import bonobo import bonobo
from bonobo.commands.util.arguments import parse_variable_argument from bonobo.registry import READER, WRITER, default_registry
from bonobo.util import require from bonobo.util.resolvers import _resolve_transformations, _resolve_options
from bonobo.util.iterators import tuplize
from bonobo.util.python import WorkingDirectoryModulesRegistry
SHORTCUTS = {
'csv': 'text/csv',
'json': 'application/json',
'pickle': 'pickle',
'plain': 'text/plain',
'text': 'text/plain',
'txt': 'text/plain',
}
REGISTRY = {
'application/json': (bonobo.JsonReader, bonobo.JsonWriter),
'pickle': (bonobo.PickleReader, bonobo.PickleWriter),
'text/csv': (bonobo.CsvReader, bonobo.CsvWriter),
'text/plain': (bonobo.FileReader, bonobo.FileWriter),
}
READER = 'reader'
WRITER = 'writer'
def resolve_factory(name, filename, factory_type, options=None):
"""
Try to resolve which transformation factory to use for this filename. User eventually provided a name, which has
priority, otherwise we try to detect it using the mimetype detection on filename.
"""
if name is None:
name = mimetypes.guess_type(filename)[0]
if name in SHORTCUTS:
name = SHORTCUTS[name]
if name is None:
_, _ext = os.path.splitext(filename)
if _ext:
_ext = _ext[1:]
if _ext in SHORTCUTS:
name = SHORTCUTS[_ext]
if options:
options = dict(map(parse_variable_argument, options))
else:
options = dict()
if not name in REGISTRY:
raise RuntimeError(
'Could not resolve {factory_type} factory for {filename} ({name}). Try providing it explicitely using -{opt} <format>.'.
format(name=name, filename=filename, factory_type=factory_type, opt=factory_type[0])
)
if factory_type == READER:
return REGISTRY[name][0], options
elif factory_type == WRITER:
return REGISTRY[name][1], options
else:
raise ValueError('Invalid factory type.')
@tuplize
def resolve_filters(filters):
registry = WorkingDirectoryModulesRegistry()
for f in filters:
try:
mod, attr = f.split(':', 1)
yield getattr(registry.require(mod), attr)
except ValueError:
yield getattr(bonobo, f)
def execute( def execute(
input, input_filename,
output, output_filename,
reader=None, reader=None,
reader_option=None, reader_option=None,
writer=None, writer=None,
writer_option=None, writer_option=None,
option=None, option=None,
filter=None, transformation=None,
): ):
reader_factory, reader_option = resolve_factory(reader, input, READER, (option or []) + (reader_option or [])) reader_factory = default_registry.get_reader_factory_for(input_filename, format=reader)
reader_options = _resolve_options((option or []) + (reader_option or []))
if output == '-': if output_filename == '-':
writer_factory, writer_option = bonobo.PrettyPrinter, {} writer_factory = bonobo.PrettyPrinter
else: else:
writer_factory, writer_option = resolve_factory(writer, output, WRITER, (option or []) + (writer_option or [])) writer_factory = default_registry.get_writer_factory_for(output_filename, format=writer)
writer_options = _resolve_options((option or []) + (writer_option or []))
filters = resolve_filters(filter) transformations = _resolve_transformations(transformation)
graph = bonobo.Graph() graph = bonobo.Graph()
graph.add_chain( graph.add_chain(
reader_factory(input, **reader_option), reader_factory(input_filename, **reader_options),
*filters, *transformations,
writer_factory(output, **writer_option), writer_factory(output_filename, **writer_options),
) )
return bonobo.run( return bonobo.run(
@ -110,8 +39,8 @@ def execute(
def register(parser): def register(parser):
parser.add_argument('input', help='Input filename.') parser.add_argument('input-filename', help='Input filename.')
parser.add_argument('output', help='Output filename.') parser.add_argument('output-filename', help='Output filename.')
parser.add_argument( parser.add_argument(
'--' + READER, '--' + READER,
'-r', '-r',
@ -124,11 +53,11 @@ def register(parser):
'Choose the writer factory if it cannot be detected from extension, or if detection is wrong (use - for console pretty print).' 'Choose the writer factory if it cannot be detected from extension, or if detection is wrong (use - for console pretty print).'
) )
parser.add_argument( parser.add_argument(
'--filter', '--transformation',
'-f', '-t',
dest='filter', dest='transformation',
action='append', action='append',
help='Add a filter between input and output', help='Add a transformation between input and output (can be used multiple times, order is preserved).',
) )
parser.add_argument( parser.add_argument(
'--option', '--option',

View File

@ -46,7 +46,17 @@ def _install_requirements(requirements):
importlib.reload(site) importlib.reload(site)
def read(filename, module, install=False, quiet=False, verbose=False, default_env_file=None, default_env=None, env_file=None, env=None): def read(
filename,
module,
install=False,
quiet=False,
verbose=False,
default_env_file=None,
default_env=None,
env_file=None,
env=None
):
import runpy import runpy
from bonobo import Graph, settings from bonobo import Graph, settings
@ -129,8 +139,20 @@ def set_env_var(e, override=False):
os.environ.setdefault(ename, evalue) os.environ.setdefault(ename, evalue)
def execute(filename, module, install=False, quiet=False, verbose=False, default_env_file=None, default_env=None, env_file=None, env=None): def execute(
graph, plugins, services = read(filename, module, install, quiet, verbose, default_env_file, default_env, env_file, env) filename,
module,
install=False,
quiet=False,
verbose=False,
default_env_file=None,
default_env=None,
env_file=None,
env=None
):
graph, plugins, services = read(
filename, module, install, quiet, verbose, default_env_file, default_env, env_file, env
)
return bonobo.run(graph, plugins=plugins, services=services) return bonobo.run(graph, plugins=plugins, services=services)

View File

@ -1,26 +0,0 @@
import json
def parse_variable_argument(arg):
try:
key, val = arg.split('=', 1)
except ValueError:
return arg, True
try:
val = json.loads(val)
except json.JSONDecodeError:
pass
return key, val
def test_parse_variable_argument():
assert parse_variable_argument('foo=bar') == ('foo', 'bar')
assert parse_variable_argument('foo="bar"') == ('foo', 'bar')
assert parse_variable_argument('sep=";"') == ('sep', ';')
assert parse_variable_argument('foo') == ('foo', True)
if __name__ == '__main__':
test_parse_var()

3
bonobo/events.py Normal file
View File

@ -0,0 +1,3 @@
ON_START = 'bonobo.on_start'
ON_TICK = 'bonobo.on_tick'
ON_STOP = 'bonobo.on_stop'

View File

@ -8,7 +8,9 @@ def extract():
test_user_password = os.getenv('TEST_USER_PASSWORD') test_user_password = os.getenv('TEST_USER_PASSWORD')
path = os.getenv('PATH') path = os.getenv('PATH')
return my_secret, test_user_password, path yield my_secret
yield test_user_password
yield path
def load(s: str): def load(s: str):

View File

@ -8,7 +8,11 @@ def extract():
env_test_number = os.getenv('ENV_TEST_NUMBER', 'number') env_test_number = os.getenv('ENV_TEST_NUMBER', 'number')
env_test_string = os.getenv('ENV_TEST_STRING', 'string') env_test_string = os.getenv('ENV_TEST_STRING', 'string')
env_user = os.getenv('USER') env_user = os.getenv('USER')
return env_test_user, env_test_number, env_test_string, env_user
yield env_test_user
yield env_test_number
yield env_test_string
yield env_user
def load(s: str): def load(s: str):

View File

@ -1,18 +0,0 @@
import bonobo
from bonobo.commands.run import get_default_services
from bonobo.nodes.factory import Factory
from bonobo.nodes.io.json import JsonDictItemsReader
normalize = Factory()
normalize[0].str().title()
normalize.move(0, 'title')
normalize.move(0, 'address')
graph = bonobo.Graph(
JsonDictItemsReader('datasets/coffeeshops.json'),
normalize,
bonobo.PrettyPrinter(),
)
if __name__ == '__main__':
bonobo.run(graph, services=get_default_services(__file__))

View File

@ -4,8 +4,7 @@ from time import sleep
from bonobo.config import create_container from bonobo.config import create_container
from bonobo.config.processors import ContextCurrifier from bonobo.config.processors import ContextCurrifier
from bonobo.plugins import get_enhancers from bonobo.util import isconfigurabletype
from bonobo.util import inspect_node, isconfigurabletype
from bonobo.util.errors import print_error from bonobo.util.errors import print_error
from bonobo.util.objects import Wrapper, get_name from bonobo.util.objects import Wrapper, get_name
@ -56,9 +55,6 @@ class LoopingExecutionContext(Wrapper):
self._started, self._stopped = False, False self._started, self._stopped = False, False
self._stack = None self._stack = None
# XXX enhancers
self._enhancers = get_enhancers(self.wrapped)
def __enter__(self): def __enter__(self):
self.start() self.start()
return self return self
@ -79,15 +75,9 @@ class LoopingExecutionContext(Wrapper):
raise TypeError( raise TypeError(
'The Configurable should be fully instanciated by now, unfortunately I got a PartiallyConfigured object...' 'The Configurable should be fully instanciated by now, unfortunately I got a PartiallyConfigured object...'
) )
# XXX enhance that, maybe giving hints on what's missing.
# print(inspect_node(self.wrapped))
self._stack.setup(self) self._stack.setup(self)
for enhancer in self._enhancers:
with unrecoverable(self.handle_error):
enhancer.start(self)
def loop(self): def loop(self):
"""Generic loop. A bit boring. """ """Generic loop. A bit boring. """
while self.alive: while self.alive:

View File

@ -1,16 +1,16 @@
import traceback import traceback
from queue import Empty from queue import Empty
from time import sleep from time import sleep
from types import GeneratorType
from bonobo import settings from bonobo.constants import NOT_MODIFIED, BEGIN, END
from bonobo.constants import INHERIT_INPUT, NOT_MODIFIED, BEGIN, END
from bonobo.errors import InactiveReadableError, UnrecoverableError from bonobo.errors import InactiveReadableError, UnrecoverableError
from bonobo.execution.base import LoopingExecutionContext from bonobo.execution.base import LoopingExecutionContext
from bonobo.structs.bags import Bag from bonobo.structs.bags import Bag
from bonobo.structs.inputs import Input from bonobo.structs.inputs import Input
from bonobo.util import get_name, iserrorbag, isloopbackbag, isdict, istuple from bonobo.structs.tokens import Token
from bonobo.util import get_name, iserrorbag, isloopbackbag, isbag
from bonobo.util.compat import deprecated_alias from bonobo.util.compat import deprecated_alias
from bonobo.util.iterators import iter_if_not_sequence
from bonobo.util.statistics import WithStatistics from bonobo.util.statistics import WithStatistics
@ -49,7 +49,7 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
:param mixed value: message :param mixed value: message
""" """
for message in messages: for message in messages:
self.input.put(message) self.input.put(message if isinstance(message, (Bag, Token)) else Bag(message))
def write_sync(self, *messages): def write_sync(self, *messages):
self.write(BEGIN, *messages, END) self.write(BEGIN, *messages, END)
@ -120,23 +120,21 @@ class NodeExecutionContext(WithStatistics, LoopingExecutionContext):
def handle_results(self, input_bag, results): def handle_results(self, input_bag, results):
# self._exec_time += timer.duration # self._exec_time += timer.duration
# Put data onto output channels # Put data onto output channels
try:
results = iter_if_not_sequence(results) if isinstance(results, GeneratorType):
except TypeError: # not an iterator while True:
if results:
self.send(_resolve(input_bag, results))
else:
# case with no result, an execution went through anyway, use for stats.
# self._exec_count += 1
pass
else:
while True: # iterator
try: try:
result = next(results) result = next(results)
except StopIteration: except StopIteration:
break break
else: else:
self.send(_resolve(input_bag, result)) self.send(_resolve(input_bag, result))
elif results:
self.send(_resolve(input_bag, results))
else:
# case with no result, an execution went through anyway, use for stats.
# self._exec_count += 1
pass
def _resolve(input_bag, output): def _resolve(input_bag, output):
@ -144,22 +142,7 @@ def _resolve(input_bag, output):
if output is NOT_MODIFIED: if output is NOT_MODIFIED:
return input_bag return input_bag
if iserrorbag(output): if isbag(output):
return output return output
# If it does not look like a bag, let's create one for easier manipulation
if hasattr(output, 'apply'): # XXX TODO use isbag() ?
# Already a bag? Check if we need to set parent.
if INHERIT_INPUT in output.flags:
output.set_parent(input_bag)
return output
# If we're using kwargs ioformat, then a dict means kwargs.
if settings.IOFORMAT == settings.IOFORMAT_KWARGS and isdict(output):
return Bag(**output)
if istuple(output):
return Bag(*output)
# Either we use arg0 format, either it's "just" a value.
return Bag(output) return Bag(output)

View File

@ -13,14 +13,14 @@ class PluginExecutionContext(LoopingExecutionContext):
super().start() super().start()
with recoverable(self.handle_error): with recoverable(self.handle_error):
self.wrapped.initialize() self.wrapped.on_start()
def shutdown(self): def shutdown(self):
if self.started: if self.started:
with recoverable(self.handle_error): with recoverable(self.handle_error):
self.wrapped.finalize() self.wrapped.on_stop()
self.alive = False self.alive = False
def step(self): def step(self):
with recoverable(self.handle_error): with recoverable(self.handle_error):
self.wrapped.run() self.wrapped.on_tick()

View File

@ -1,6 +1,6 @@
import io import io
import sys import sys
from contextlib import redirect_stdout from contextlib import redirect_stdout, redirect_stderr
from colorama import Style, Fore, init from colorama import Style, Fore, init
@ -50,35 +50,50 @@ class ConsoleOutputPlugin(Plugin):
""" """
def initialize(self): # Standard outputs descriptors backup here, also used to override if needed.
_stdout = sys.stdout
_stderr = sys.stderr
# When the plugin is started, we'll set the real value of this.
isatty = False
# Whether we're on windows, or a real operating system.
iswindows = (sys.platform == 'win32')
def on_start(self):
self.prefix = '' self.prefix = ''
self.counter = 0 self.counter = 0
self._append_cache = '' self._append_cache = ''
self.isatty = sys.stdout.isatty()
self.iswindows = (sys.platform == 'win32')
self._stdout = sys.stdout self.isatty = self._stdout.isatty()
self.stdout = IOBuffer() self.stdout = IOBuffer()
self.redirect_stdout = redirect_stdout(self._stdout if self.iswindows else self.stdout) self.redirect_stdout = redirect_stdout(self._stdout if self.iswindows else self.stdout)
self.redirect_stdout.__enter__() self.redirect_stdout.__enter__()
def run(self): self.stderr = IOBuffer()
self.redirect_stderr = redirect_stderr(self._stderr if self.iswindows else self.stderr)
self.redirect_stderr.__enter__()
def on_tick(self):
if self.isatty and not self.iswindows: if self.isatty and not self.iswindows:
self._write(self.context.parent, rewind=True) self._write(self.context.parent, rewind=True)
else: else:
pass # not a tty, or windows, so we'll ignore stats output pass # not a tty, or windows, so we'll ignore stats output
def finalize(self): def on_stop(self):
self._write(self.context.parent, rewind=False) self._write(self.context.parent, rewind=False)
self.redirect_stderr.__exit__(None, None, None)
self.redirect_stdout.__exit__(None, None, None) self.redirect_stdout.__exit__(None, None, None)
def write(self, context, prefix='', rewind=True, append=None): def write(self, context, prefix='', rewind=True, append=None):
t_cnt = len(context) t_cnt = len(context)
if not self.iswindows: if not self.iswindows:
buffered = self.stdout.switch() for line in self.stdout.switch().split('\n')[:-1]:
for line in buffered.split('\n')[:-1]: print(line + CLEAR_EOL, file=self._stdout)
print(line + CLEAR_EOL, file=sys.stderr) for line in self.stderr.switch().split('\n')[:-1]:
print(line + CLEAR_EOL, file=self._stderr)
alive_color = Style.BRIGHT alive_color = Style.BRIGHT
dead_color = Style.BRIGHT + Fore.BLACK dead_color = Style.BRIGHT + Fore.BLACK
@ -117,7 +132,7 @@ class ConsoleOutputPlugin(Plugin):
' ', ' ',
) )
) )
print(prefix + _line + '\033[0K', file=sys.stderr) print(prefix + _line + CLEAR_EOL, file=self._stderr)
if append: if append:
# todo handle multiline # todo handle multiline
@ -128,13 +143,13 @@ class ConsoleOutputPlugin(Plugin):
CLEAR_EOL CLEAR_EOL
) )
), ),
file=sys.stderr file=self._stderr
) )
t_cnt += 1 t_cnt += 1
if rewind: if rewind:
print(CLEAR_EOL, file=sys.stderr) print(CLEAR_EOL, file=self._stderr)
print(MOVE_CURSOR_UP(t_cnt + 2), file=sys.stderr) print(MOVE_CURSOR_UP(t_cnt + 2), file=self._stderr)
def _write(self, graph_context, rewind): def _write(self, graph_context, rewind):
if settings.PROFILE.get(): if settings.PROFILE.get():

73
bonobo/ext/django.py Normal file
View File

@ -0,0 +1,73 @@
from logging import getLogger
from colorama import Fore, Back, Style
from django.core.management.base import BaseCommand, OutputWrapper
import bonobo
import bonobo.util
from bonobo.commands.run import get_default_services
from bonobo.ext.console import ConsoleOutputPlugin
from bonobo.util.term import CLEAR_EOL
class ETLCommand(BaseCommand):
GraphType = bonobo.Graph
def create_or_update(self, model, *, defaults=None, save=True, **kwargs):
"""
Create or update a django model instance.
:param model:
:param defaults:
:param kwargs:
:return: object, created, updated
"""
obj, created = model._default_manager.get_or_create(defaults=defaults, **kwargs)
updated = False
if not created:
for k, v in defaults.items():
if getattr(obj, k) != v:
setattr(obj, k, v)
updated = True
if updated and save:
obj.save()
return obj, created, updated
def get_graph(self, *args, **options):
def not_implemented():
raise NotImplementedError('You must implement {}.get_graph() method.'.format(self))
return self.GraphType(not_implemented)
def get_services(self):
return {}
@property
def logger(self):
try:
return self._logger
except AttributeError:
self._logger = getLogger(type(self).__module__)
return self._logger
def info(self, *args, **kwargs):
self.logger.info(*args, **kwargs)
def handle(self, *args, **options):
_stdout_backup, _stderr_backup = self.stdout, self.stderr
self.stdout = OutputWrapper(ConsoleOutputPlugin._stdout, ending=CLEAR_EOL + '\n')
self.stderr = OutputWrapper(ConsoleOutputPlugin._stderr, ending=CLEAR_EOL + '\n')
self.stderr.style_func = lambda x: Fore.LIGHTRED_EX + Back.RED + '!' + Style.RESET_ALL + ' ' + x
result = bonobo.run(
self.get_graph(*args, **options),
services=self.get_services(),
)
self.stdout = _stdout_backup
return '\nReturn Value: ' + str(result)

43
bonobo/ext/google.py Normal file
View File

@ -0,0 +1,43 @@
import os
import httplib2
from apiclient import discovery
from oauth2client import client, tools
from oauth2client.file import Storage
from oauth2client.tools import argparser
HOME_DIR = os.path.expanduser('~')
GOOGLE_SCOPES = ('https://www.googleapis.com/auth/spreadsheets', )
GOOGLE_SECRETS = os.path.join(HOME_DIR, '.cache/secrets/client_secrets.json')
def get_credentials():
"""Gets valid user credentials from storage.
If nothing has been stored, or if the stored credentials are invalid,
the OAuth2 flow is completed to obtain the new credentials.
Returns:
Credentials, the obtained credential.
"""
credential_dir = os.path.join(HOME_DIR, '.cache', __package__, 'credentials')
if not os.path.exists(credential_dir):
os.makedirs(credential_dir)
credential_path = os.path.join(credential_dir, 'googleapis.json')
store = Storage(credential_path)
credentials = store.get()
if not credentials or credentials.invalid:
flow = client.flow_from_clientsecrets(GOOGLE_SECRETS, GOOGLE_SCOPES)
flow.user_agent = 'Bonobo ETL (https://www.bonobo-project.org/)'
flags = argparser.parse_args(['--noauth_local_webserver'])
credentials = tools.run_flow(flow, store, flags)
print('Storing credentials to ' + credential_path)
return credentials
def get_google_spreadsheets_api_client():
credentials = get_credentials()
http = credentials.authorize(httplib2.Http())
discoveryUrl = 'https://sheets.googleapis.com/$discovery/rest?version=v4'
return discovery.build('sheets', 'v4', http=http, discoveryServiceUrl=discoveryUrl, cache_discovery=False)

View File

@ -14,14 +14,14 @@ def path_str(path):
class OpenDataSoftAPI(Configurable): class OpenDataSoftAPI(Configurable):
dataset = Option(str, positional=True) dataset = Option(str, positional=True)
endpoint = Option(str, default='{scheme}://{netloc}{path}') endpoint = Option(str, required=False, default='{scheme}://{netloc}{path}')
scheme = Option(str, default='https') scheme = Option(str, required=False, default='https')
netloc = Option(str, default='data.opendatasoft.com') netloc = Option(str, required=False, default='data.opendatasoft.com')
path = Option(path_str, default='/api/records/1.0/search/') path = Option(path_str, required=False, default='/api/records/1.0/search/')
rows = Option(int, default=500) rows = Option(int, required=False, default=500)
limit = Option(int, required=False) limit = Option(int, required=False)
timezone = Option(str, default='Europe/Paris') timezone = Option(str, required=False, default='Europe/Paris')
kwargs = Option(dict, default=dict) kwargs = Option(dict, required=False, default=dict)
@ContextProcessor @ContextProcessor
def compute_path(self, context): def compute_path(self, context):
@ -44,7 +44,11 @@ class OpenDataSoftAPI(Configurable):
break break
for row in records: for row in records:
yield {**row.get('fields', {}), 'geometry': row.get('geometry', {})} yield {
**row.get('fields', {}),
'geometry': row.get('geometry', {}),
'recordid': row.get('recordid'),
}
start += self.rows start += self.rows

View File

@ -1,219 +0,0 @@
import functools
import warnings
from functools import partial
from bonobo import Bag
from bonobo.config import Configurable, Method
_isarg = lambda item: type(item) is int
_iskwarg = lambda item: type(item) is str
class Operation():
def __init__(self, item, callable):
self.item = item
self.callable = callable
def __repr__(self):
return '<operation {} on {}>'.format(self.callable.__name__, self.item)
def apply(self, *args, **kwargs):
if _isarg(self.item):
return (*args[0:self.item], self.callable(args[self.item]), *args[self.item + 1:]), kwargs
if _iskwarg(self.item):
return args, {**kwargs, self.item: self.callable(kwargs.get(self.item))}
raise RuntimeError('Houston, we have a problem...')
class FactoryOperation():
def __init__(self, factory, callable):
self.factory = factory
self.callable = callable
def __repr__(self):
return '<factory operation {}>'.format(self.callable.__name__)
def apply(self, *args, **kwargs):
return self.callable(*args, **kwargs)
CURSOR_TYPES = {}
def operation(mixed):
def decorator(m, ctype=mixed):
def lazy_operation(self, *args, **kwargs):
@functools.wraps(m)
def actual_operation(x):
return m(self, x, *args, **kwargs)
self.factory.operations.append(Operation(self.item, actual_operation))
return CURSOR_TYPES[ctype](self.factory, self.item) if ctype else self
return lazy_operation
return decorator if isinstance(mixed, str) else decorator(mixed, ctype=None)
def factory_operation(m):
def lazy_operation(self, *config):
@functools.wraps(m)
def actual_operation(*args, **kwargs):
return m(self, *config, *args, **kwargs)
self.operations.append(FactoryOperation(self, actual_operation))
return self
return lazy_operation
class Cursor():
_type = None
def __init__(self, factory, item):
self.factory = factory
self.item = item
@operation('dict')
def dict(self, x):
return x if isinstance(x, dict) else dict(x)
@operation('int')
def int(self):
pass
@operation('str')
def str(self, x):
return x if isinstance(x, str) else str(x)
@operation('list')
def list(self):
pass
@operation('tuple')
def tuple(self):
pass
def __getattr__(self, item):
"""
Fallback to type methods if they exist, for example StrCursor.upper will use str.upper if not overriden, etc.
:param item:
"""
if self._type and item in self._type.__dict__:
method = self._type.__dict__[item]
@operation
@functools.wraps(method)
def _operation(self, x, *args, **kwargs):
return method(x, *args, **kwargs)
setattr(self, item, partial(_operation, self))
return getattr(self, item)
raise AttributeError('Unknown operation {}.{}().'.format(
type(self).__name__,
item,
))
CURSOR_TYPES['default'] = Cursor
class DictCursor(Cursor):
_type = dict
@operation('default')
def get(self, x, path):
return x.get(path)
@operation
def map_keys(self, x, mapping):
return {mapping.get(k): v for k, v in x.items()}
CURSOR_TYPES['dict'] = DictCursor
class StringCursor(Cursor):
_type = str
CURSOR_TYPES['str'] = StringCursor
class Factory(Configurable):
initialize = Method(required=False)
def __init__(self, *args, **kwargs):
warnings.warn(
__file__ +
' is experimental, API may change in the future, use it as a preview only and knowing the risks.',
FutureWarning
)
super(Factory, self).__init__(*args, **kwargs)
self.default_cursor_type = 'default'
self.operations = []
if self.initialize is not None:
self.initialize(self)
@factory_operation
def move(self, _from, _to, *args, **kwargs):
if _from == _to:
return args, kwargs
if _isarg(_from):
value = args[_from]
args = args[:_from] + args[_from + 1:]
elif _iskwarg(_from):
value = kwargs[_from]
kwargs = {k: v for k, v in kwargs if k != _from}
else:
raise RuntimeError('Houston, we have a problem...')
if _isarg(_to):
return (*args[:_to], value, *args[_to + 1:]), kwargs
elif _iskwarg(_to):
return args, {**kwargs, _to: value}
else:
raise RuntimeError('Houston, we have a problem...')
def __call__(self, *args, **kwargs):
print('factory call on', args, kwargs)
for operation in self.operations:
args, kwargs = operation.apply(*args, **kwargs)
print(' ... after', operation, 'got', args, kwargs)
return Bag(*args, **kwargs)
def __getitem__(self, item):
return CURSOR_TYPES[self.default_cursor_type](self, item)
if __name__ == '__main__':
f = Factory()
f[0].dict().map_keys({'foo': 'F00'})
f['foo'].str().upper()
print('operations:', f.operations)
print(f({'foo': 'bisou'}, foo='blah'))
'''
specs:
- rename keys of an input dict (in args, or kwargs) using a translation map.
f = Factory()
f[0]
f['xxx'] =
f[0].dict().get('foo.bar').move_to('foo.baz').apply(str.upper)
f[0].get('foo.*').items().map(str.lower)
f['foo'].keys_map({
'a': 'b'
})
'''

View File

@ -1,7 +1,3 @@
from bonobo.config import Configurable
from bonobo.util.objects import get_attribute_or_create
class Plugin: class Plugin:
""" """
A plugin is an extension to the core behavior of bonobo. If you're writing transformations, you should not need A plugin is an extension to the core behavior of bonobo. If you're writing transformations, you should not need
@ -11,30 +7,8 @@ class Plugin:
respectively permits an interactive output on an ANSI console and a rich output in a jupyter notebook. respectively permits an interactive output on an ANSI console and a rich output in a jupyter notebook.
Warning: THE PLUGIN API IS PRE-ALPHA AND WILL EVOLVE BEFORE 1.0, DO NOT RELY ON IT BEING STABLE! Warning: THE PLUGIN API IS PRE-ALPHA AND WILL EVOLVE BEFORE 1.0, DO NOT RELY ON IT BEING STABLE!
""" """
def __init__(self, context): def __init__(self, context):
self.context = context self.context = context
def initialize(self):
pass
def run(self):
pass
def finalize(self):
pass
def get_enhancers(obj):
try:
return get_attribute_or_create(obj, '__enhancers__', list())
except AttributeError:
return list()
class NodeEnhancer(Configurable):
def __matmul__(self, other):
get_enhancers(other).append(self)
return other

90
bonobo/registry.py Normal file
View File

@ -0,0 +1,90 @@
import mimetypes
import os
from bonobo import JsonReader, CsvReader, PickleReader, FileReader, FileWriter, PickleWriter, CsvWriter, JsonWriter
FILETYPE_CSV = 'text/csv'
FILETYPE_JSON = 'application/json'
FILETYPE_PICKLE = 'pickle'
FILETYPE_PLAIN = 'text/plain'
READER = 'reader'
WRITER = 'writer'
class Registry:
ALIASES = {
'csv': FILETYPE_CSV,
'json': FILETYPE_JSON,
'pickle': FILETYPE_PICKLE,
'plain': FILETYPE_PLAIN,
'text': FILETYPE_PLAIN,
'txt': FILETYPE_PLAIN,
}
FACTORIES = {
READER: {
FILETYPE_JSON: JsonReader,
FILETYPE_CSV: CsvReader,
FILETYPE_PICKLE: PickleReader,
FILETYPE_PLAIN: FileReader,
},
WRITER: {
FILETYPE_JSON: JsonWriter,
FILETYPE_CSV: CsvWriter,
FILETYPE_PICKLE: PickleWriter,
FILETYPE_PLAIN: FileWriter,
},
}
def get_factory_for(self, kind, name, *, format=None):
if not kind in self.FACTORIES:
raise KeyError('Unknown factory kind {!r}.'.format(kind))
if format is None and name is None:
raise RuntimeError('Cannot guess factory without at least a filename or a format.')
# Guess mimetype if possible
if format is None:
format = mimetypes.guess_type(name)[0]
# Guess from extension if possible
if format is None:
_, _ext = os.path.splitext(name)
if _ext:
format = _ext[1:]
# Apply aliases
if format in self.ALIASES:
format = self.ALIASES[format]
if format is None or not format in self.FACTORIES[kind]:
raise RuntimeError(
'Could not resolve {kind} factory for {name} ({format}).'.format(kind=kind, name=name, format=format)
)
return self.FACTORIES[kind][format]
def get_reader_factory_for(self, name, *, format=None):
"""
Returns a callable to build a reader for the provided filename, eventually forcing a format.
:param name: filename
:param format: format
:return: type
"""
return self.get_factory_for(READER, name, format=format)
def get_writer_factory_for(self, name, *, format=None):
"""
Returns a callable to build a writer for the provided filename, eventually forcing a format.
:param name: filename
:param format: format
:return: type
"""
return self.get_factory_for(WRITER, name, format=format)
default_registry = Registry()

View File

@ -1,5 +1,6 @@
import itertools import itertools
from bonobo.structs.tokens import Token
from bonobo.constants import INHERIT_INPUT, LOOPBACK from bonobo.constants import INHERIT_INPUT, LOOPBACK
__all__ = [ __all__ = [
@ -35,11 +36,55 @@ class Bag:
default_flags = () default_flags = ()
def __new__(cls, *args, _flags=None, _parent=None, **kwargs):
# Handle the special case where we call Bag's constructor with only one bag or token as argument.
if len(args) == 1 and len(kwargs) == 0:
if isinstance(args[0], Bag):
raise ValueError('Bag cannot be instanciated with a bag (for now ...).')
if isinstance(args[0], Token):
return args[0]
# Otherwise, type will handle that for us.
return super().__new__(cls)
def __init__(self, *args, _flags=None, _parent=None, **kwargs): def __init__(self, *args, _flags=None, _parent=None, **kwargs):
self._flags = type(self).default_flags + (_flags or ()) self._flags = type(self).default_flags + (_flags or ())
self._parent = _parent self._parent = _parent
self._args = args
self._kwargs = kwargs if len(args) == 1 and len(kwargs) == 0:
# If we only have one argument, that may be because we're using the shorthand syntax.
mixed = args[0]
if isinstance(mixed, Bag):
# Just duplicate the bag.
self._args = mixed.args
self._kwargs = mixed.kwargs
elif isinstance(mixed, tuple):
if not len(mixed):
# Empty bag.
self._args = ()
self._kwargs = {}
elif isinstance(mixed[-1], dict):
# Args + Kwargs
self._args = mixed[:-1]
self._kwargs = mixed[-1]
else:
# Args only
self._args = mixed
self._kwargs = {}
elif isinstance(mixed, dict):
# Kwargs only
self._args = ()
self._kwargs = mixed
else:
self._args = args
self._kwargs = {}
else:
# Otherwise, lets get args/kwargs from the constructor.
self._args = args
self._kwargs = kwargs
@property @property
def args(self): def args(self):
@ -105,23 +150,24 @@ class Bag:
if isinstance(other, Bag) and other.args == self.args and other.kwargs == self.kwargs: if isinstance(other, Bag) and other.args == self.args and other.kwargs == self.kwargs:
return True return True
# tuple of (tuple, dict) # tuple
if isinstance(other, tuple) and len(other) == 2 and other[0] == self.args and other[1] == self.kwargs: if isinstance(other, tuple):
return True # self == ()
if not len(other):
return not self.args and not self.kwargs
# tuple (aka args) if isinstance(other[-1], dict):
if isinstance(other, tuple) and other == self.args: # self == (*args, {**kwargs}) ?
return True return other[:-1] == self.args and other[-1] == self.kwargs
# self == (*args) ?
return other == self.args and not self.kwargs
# dict (aka kwargs) # dict (aka kwargs)
if isinstance(other, dict) and not self.args and other == self.kwargs: if isinstance(other, dict) and not self.args and other == self.kwargs:
return True return True
# arg0 return len(self.args) == 1 and not self.kwargs and self.args[0] == other
if len(self.args) == 1 and not len(self.kwargs) and self.args[0] == other:
return True
return False
def __repr__(self): def __repr__(self):
return '<{} ({})>'.format( return '<{} ({})>'.format(

View File

@ -1,5 +1,4 @@
from bonobo.util.collections import sortedlist from bonobo.util.collections import sortedlist, ensure_tuple
from bonobo.util.iterators import ensure_tuple
from bonobo.util.compat import deprecated, deprecated_alias from bonobo.util.compat import deprecated, deprecated_alias
from bonobo.util.inspect import ( from bonobo.util.inspect import (
inspect_node, inspect_node,

View File

@ -1,6 +1,48 @@
import bisect import bisect
import functools
class sortedlist(list): class sortedlist(list):
def insort(self, x): def insort(self, x):
bisect.insort(self, x) bisect.insort(self, x)
def ensure_tuple(tuple_or_mixed):
"""
If it's not a tuple, let's make a tuple of one item.
Otherwise, not changed.
:param tuple_or_mixed:
:return: tuple
"""
if isinstance(tuple_or_mixed, tuple):
return tuple_or_mixed
return (tuple_or_mixed, )
def tuplize(generator):
""" Takes a generator and make it a tuple-returning function. As a side
effect, it can also decorate any iterator-returning function to force
return value to be a tuple.
>>> tuplized_lambda = tuplize(lambda: [1, 2, 3])
>>> tuplized_lambda()
(1, 2, 3)
>>> @tuplize
... def my_generator():
... yield 1
... yield 2
... yield 3
...
>>> my_generator()
(1, 2, 3)
"""
@functools.wraps(generator)
def tuplized(*args, **kwargs):
return tuple(generator(*args, **kwargs))
return tuplized

View File

@ -1,48 +0,0 @@
""" Iterator utilities. """
import functools
def force_iterator(mixed):
"""Sudo make me an iterator.
Deprecated?
:param mixed:
:return: Iterator, baby.
"""
if isinstance(mixed, str):
return [mixed]
try:
return iter(mixed)
except TypeError:
return [mixed] if mixed else []
def ensure_tuple(tuple_or_mixed):
if isinstance(tuple_or_mixed, tuple):
return tuple_or_mixed
return (tuple_or_mixed, )
def tuplize(generator):
""" Takes a generator and make it a tuple-returning function. As a side
effect, it can also decorate any iterator-returning function to force
return value to be a tuple.
"""
@functools.wraps(generator)
def tuplized(*args, **kwargs):
return tuple(generator(*args, **kwargs))
return tuplized
def iter_if_not_sequence(mixed):
if isinstance(mixed, (
dict,
list,
str,
bytes,
)):
raise TypeError(type(mixed).__name__)
return iter(mixed)

View File

@ -1,7 +1,3 @@
import functools
from functools import partial
def get_name(mixed): def get_name(mixed):
try: try:
return mixed.__name__ return mixed.__name__
@ -220,6 +216,15 @@ class ValueHolder:
def __len__(self): def __len__(self):
return len(self._value) return len(self._value)
def __contains__(self, item):
return item in self._value
def __getitem__(self, item):
return self._value[item]
def __setitem__(self, key, value):
self._value[key] = value
def get_attribute_or_create(obj, attr, default): def get_attribute_or_create(obj, attr, default):
try: try:

61
bonobo/util/resolvers.py Normal file
View File

@ -0,0 +1,61 @@
"""
This package is considered private, and should only be used within bonobo.
"""
import json
import bonobo
from bonobo.util.collections import tuplize
from bonobo.util.python import WorkingDirectoryModulesRegistry
def _parse_option(option):
"""
Parse a 'key=val' option string into a python (key, val) pair
:param option: str
:return: tuple
"""
try:
key, val = option.split('=', 1)
except ValueError:
return option, True
try:
val = json.loads(val)
except json.JSONDecodeError:
pass
return key, val
def _resolve_options(options=None):
"""
Resolve a collection of option strings (eventually coming from command line) into a python dictionary.
:param options: tuple[str]
:return: dict
"""
if options:
return dict(map(_parse_option, options))
return dict()
@tuplize
def _resolve_transformations(transformations):
"""
Resolve a collection of strings into the matching python objects, defaulting to bonobo namespace if no package is provided.
Syntax for each string is path.to.package:attribute
:param transformations: tuple(str)
:return: tuple(object)
"""
registry = WorkingDirectoryModulesRegistry()
for t in transformations:
try:
mod, attr = t.split(':', 1)
yield getattr(registry.require(mod), attr)
except ValueError:
yield getattr(bonobo, t)

View File

@ -1,8 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import sys import datetime
import os import os
import sys
sys.path.insert(0, os.path.abspath('..')) sys.path.insert(0, os.path.abspath('..'))
sys.path.insert(0, os.path.abspath('_themes')) sys.path.insert(0, os.path.abspath('_themes'))
@ -36,8 +37,8 @@ master_doc = 'index'
# General information about the project. # General information about the project.
project = 'Bonobo' project = 'Bonobo'
copyright = '2012-2017, Romain Dorgueil'
author = 'Romain Dorgueil' author = 'Romain Dorgueil'
copyright = '2012-{}, {}'.format(datetime.datetime.now().year, author)
# The version info for the project you're documenting, acts as replacement for # The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the # |version| and |release|, also used in various other places throughout the

View File

@ -32,6 +32,100 @@ Iterable
Something we can iterate on, in python, so basically anything you'd be able to use in a `for` loop. Something we can iterate on, in python, so basically anything you'd be able to use in a `for` loop.
Concepts
::::::::
Whatever kind of transformation you want to use, there are a few common concepts you should know about.
Input
-----
All input is retrieved via the call arguments. Each line of input means one call to the callable provided. Arguments
will be, in order:
* Injected dependencies (database, http, filesystem, ...)
* Position based arguments
* Keyword based arguments
You'll see below how to pass each of those.
Output
------
Each callable can return/yield different things (all examples will use yield, but if there is only one output per input
line, you can also return your output row and expect the exact same behaviour).
Let's see the rules (first to match wins).
1. A flag, eventually followed by something else, marks a special behaviour. If it supports it, the remaining part of
the output line will be interpreted using the same rules, and some flags can be combined.
**NOT_MODIFIED**
**NOT_MODIFIED** tells bonobo to use the input row unmodified as the output.
*CANNOT be combined*
Example:
.. code-block:: python
from bonobo import NOT_MODIFIED
def output_will_be_same_as_input(*args, **kwargs):
yield NOT_MODIFIED
**APPEND**
**APPEND** tells bonobo to append this output to the input (positional arguments will equal `input_args + output_args`,
keyword arguments will equal `{**input_kwargs, **output_kwargs}`).
*CAN be combined, but not with itself*
.. code-block:: python
from bonobo import APPEND
def output_will_be_appended_to_input(*args, **kwargs):
yield APPEND, 'foo', 'bar', {'eat_at': 'joe'}
**LOOPBACK**
**LOOPBACK** tells bonobo that this output must be looped back into our own input queue, allowing to create the stream
processing version of recursive algorithms.
*CAN be combined, but not with itself*
.. code-block:: python
from bonobo import LOOPBACK
def output_will_be_sent_to_self(*args, **kwargs):
yield LOOPBACK, 'Hello, I am the future "you".'
**CHANNEL(...)**
**CHANNEL(...)** tells bonobo that this output does not use the default channel and is routed through another path.
This is something you should probably not use unless your data flow design is complex, and if you're not certain
about it, it probably means that it is not the feature you're looking for.
*CAN be combined, but not with itself*
.. code-block:: python
from bonobo import CHANNEL
def output_will_be_sent_to_self(*args, **kwargs):
yield CHANNEL("errors"), 'That is not cool.'
2. Once all flags are "consumed", the remaining part is interpreted.
* If it is a :class:`bonobo.Bag` instance, then it's used directly.
* If it is a :class:`dict` then a kwargs-only :class:`bonobo.Bag` will be created.
* If it is a :class:`tuple` then an args-only :class:`bonobo.Bag` will be created, unless its last argument is a
:class:`dict` in which case a args+kwargs :class:`bonobo.Bag` will be created.
* If it's something else, it will be used to create a one-arg-only :class:`bonobo.Bag`.
Function based transformations Function based transformations
:::::::::::::::::::::::::::::: ::::::::::::::::::::::::::::::

View File

@ -1,6 +1,6 @@
-e .[docker] -e .[docker]
appdirs==1.4.3 appdirs==1.4.3
bonobo-docker==0.2.12 bonobo-docker==0.5.0
certifi==2017.7.27.1 certifi==2017.7.27.1
chardet==3.0.4 chardet==3.0.4
colorama==0.3.9 colorama==0.3.9

View File

View File

@ -12,7 +12,7 @@ def test_node_string():
output = context.get_buffer() output = context.get_buffer()
assert len(output) == 1 assert len(output) == 1
assert output[0] == (('foo', ), {}) assert output[0] == 'foo'
def g(): def g():
yield 'foo' yield 'foo'
@ -23,8 +23,8 @@ def test_node_string():
output = context.get_buffer() output = context.get_buffer()
assert len(output) == 2 assert len(output) == 2
assert output[0] == (('foo', ), {}) assert output[0] == 'foo'
assert output[1] == (('bar', ), {}) assert output[1] == 'bar'
def test_node_bytes(): def test_node_bytes():
@ -36,7 +36,7 @@ def test_node_bytes():
output = context.get_buffer() output = context.get_buffer()
assert len(output) == 1 assert len(output) == 1
assert output[0] == ((b'foo', ), {}) assert output[0] == b'foo'
def g(): def g():
yield b'foo' yield b'foo'
@ -47,8 +47,8 @@ def test_node_bytes():
output = context.get_buffer() output = context.get_buffer()
assert len(output) == 2 assert len(output) == 2
assert output[0] == ((b'foo', ), {}) assert output[0] == b'foo'
assert output[1] == ((b'bar', ), {}) assert output[1] == b'bar'
def test_node_dict(): def test_node_dict():
@ -102,3 +102,80 @@ def test_node_dict_chained():
assert len(output) == 2 assert len(output) == 2
assert output[0] == {'id': 1, 'name': 'FOO'} assert output[0] == {'id': 1, 'name': 'FOO'}
assert output[1] == {'id': 2, 'name': 'BAR'} assert output[1] == {'id': 2, 'name': 'BAR'}
def test_node_tuple():
def f():
return 'foo', 'bar'
with BufferingNodeExecutionContext(f) as context:
context.write_sync(Bag())
output = context.get_buffer()
assert len(output) == 1
assert output[0] == ('foo', 'bar')
def g():
yield 'foo', 'bar'
yield 'foo', 'baz'
with BufferingNodeExecutionContext(g) as context:
context.write_sync(Bag())
output = context.get_buffer()
assert len(output) == 2
assert output[0] == ('foo', 'bar')
assert output[1] == ('foo', 'baz')
def test_node_tuple_chained():
strategy = NaiveStrategy(GraphExecutionContextType=BufferingGraphExecutionContext)
def uppercase(*args):
return tuple(map(str.upper, args))
def f():
return 'foo', 'bar'
graph = Graph(f, uppercase)
context = strategy.execute(graph)
output = context.get_buffer()
assert len(output) == 1
assert output[0] == ('FOO', 'BAR')
def g():
yield 'foo', 'bar'
yield 'foo', 'baz'
graph = Graph(g, uppercase)
context = strategy.execute(graph)
output = context.get_buffer()
assert len(output) == 2
assert output[0] == ('FOO', 'BAR')
assert output[1] == ('FOO', 'BAZ')
def test_node_tuple_dict():
def f():
return 'foo', 'bar', {'id': 1}
with BufferingNodeExecutionContext(f) as context:
context.write_sync(Bag())
output = context.get_buffer()
assert len(output) == 1
assert output[0] == ('foo', 'bar', {'id': 1})
def g():
yield 'foo', 'bar', {'id': 1}
yield 'foo', 'baz', {'id': 2}
with BufferingNodeExecutionContext(g) as context:
context.write_sync(Bag())
output = context.get_buffer()
assert len(output) == 2
assert output[0] == ('foo', 'bar', {'id': 1})
assert output[1] == ('foo', 'baz', {'id': 2})

View File

@ -28,9 +28,7 @@ def test_write_csv_to_file_kwargs(tmpdir, add_kwargs):
fs, filename, services = csv_tester.get_services_for_writer(tmpdir) fs, filename, services = csv_tester.get_services_for_writer(tmpdir)
with NodeExecutionContext(CsvWriter(filename, **add_kwargs), services=services) as context: with NodeExecutionContext(CsvWriter(filename, **add_kwargs), services=services) as context:
context.write(BEGIN, Bag(**{'foo': 'bar'}), Bag(**{'foo': 'baz', 'ignore': 'this'}), END) context.write_sync({'foo': 'bar'}, {'foo': 'baz', 'ignore': 'this'})
context.step()
context.step()
with fs.open(filename) as fp: with fs.open(filename) as fp:
assert fp.read() == 'foo\nbar\nbaz\n' assert fp.read() == 'foo\nbar\nbaz\n'

View File

@ -1,7 +1,6 @@
import pytest import pytest
from bonobo import Bag, JsonReader, JsonWriter, settings from bonobo import JsonReader, JsonWriter, settings
from bonobo.constants import BEGIN, END
from bonobo.execution.node import NodeExecutionContext from bonobo.execution.node import NodeExecutionContext
from bonobo.util.testing import FilesystemTester from bonobo.util.testing import FilesystemTester
@ -29,8 +28,7 @@ def test_write_json_kwargs(tmpdir, add_kwargs):
fs, filename, services = json_tester.get_services_for_writer(tmpdir) fs, filename, services = json_tester.get_services_for_writer(tmpdir)
with NodeExecutionContext(JsonWriter(filename, **add_kwargs), services=services) as context: with NodeExecutionContext(JsonWriter(filename, **add_kwargs), services=services) as context:
context.write(BEGIN, Bag(**{'foo': 'bar'}), END) context.write_sync({'foo': 'bar'})
context.step()
with fs.open(filename) as fp: with fs.open(filename) as fp:
assert fp.read() == '[{"foo": "bar"}]' assert fp.read() == '[{"foo": "bar"}]'

View File

@ -14,7 +14,7 @@ def test_write_pickled_dict_to_file(tmpdir):
fs, filename, services = pickle_tester.get_services_for_writer(tmpdir) fs, filename, services = pickle_tester.get_services_for_writer(tmpdir)
with NodeExecutionContext(PickleWriter(filename), services=services) as context: with NodeExecutionContext(PickleWriter(filename), services=services) as context:
context.write_sync(Bag({'foo': 'bar'}), Bag({'foo': 'baz', 'ignore': 'this'})) context.write_sync(Bag(({'foo': 'bar'}, {})), Bag(({'foo': 'baz', 'ignore': 'this'}, {})))
with fs.open(filename, 'rb') as fp: with fs.open(filename, 'rb') as fp:
assert pickle.loads(fp.read()) == {'foo': 'bar'} assert pickle.loads(fp.read()) == {'foo': 'bar'}
@ -27,7 +27,7 @@ def test_read_pickled_list_from_file(tmpdir):
fs, filename, services = pickle_tester.get_services_for_reader(tmpdir) fs, filename, services = pickle_tester.get_services_for_reader(tmpdir)
with BufferingNodeExecutionContext(PickleReader(filename), services=services) as context: with BufferingNodeExecutionContext(PickleReader(filename), services=services) as context:
context.write_sync(Bag()) context.write_sync(())
output = context.get_buffer() output = context.get_buffer()
assert len(output) == 2 assert len(output) == 2

View File

@ -1,8 +1,10 @@
import pickle import pickle
from unittest.mock import Mock from unittest.mock import Mock
import pytest
from bonobo import Bag from bonobo import Bag
from bonobo.constants import INHERIT_INPUT from bonobo.constants import INHERIT_INPUT, BEGIN
from bonobo.structs import Token from bonobo.structs import Token
args = ( args = (
@ -31,6 +33,32 @@ def test_basic():
my_callable2.assert_called_once_with(*args, **kwargs) my_callable2.assert_called_once_with(*args, **kwargs)
def test_constructor_empty():
a, b = Bag(), Bag()
assert a == b
assert a.args is ()
assert a.kwargs == {}
@pytest.mark.parametrize(('arg_in', 'arg_out'), (
((), ()),
({}, ()),
(('a', 'b', 'c'), None),
))
def test_constructor_shorthand(arg_in, arg_out):
if arg_out is None:
arg_out = arg_in
assert Bag(arg_in) == arg_out
def test_constructor_kwargs_only():
assert Bag(foo='bar') == {'foo': 'bar'}
def test_constructor_identity():
assert Bag(BEGIN) is BEGIN
def test_inherit(): def test_inherit():
bag = Bag('a', a=1) bag = Bag('a', a=1)
bag2 = Bag.inherit('b', b=2, _parent=bag) bag2 = Bag.inherit('b', b=2, _parent=bag)
@ -92,7 +120,7 @@ def test_pickle():
assert unpickled == bag assert unpickled == bag
def test_eq_operator(): def test_eq_operator_bag():
assert Bag('foo') == Bag('foo') assert Bag('foo') == Bag('foo')
assert Bag('foo') != Bag('bar') assert Bag('foo') != Bag('bar')
assert Bag('foo') is not Bag('foo') assert Bag('foo') is not Bag('foo')
@ -100,6 +128,35 @@ def test_eq_operator():
assert Token('foo') != Bag('foo') assert Token('foo') != Bag('foo')
def test_eq_operator_tuple_mixed():
assert Bag('foo', bar='baz') == ('foo', {'bar': 'baz'})
assert Bag('foo') == ('foo', {})
assert Bag() == ({}, )
def test_eq_operator_tuple_not_mixed():
assert Bag('foo', 'bar') == ('foo', 'bar')
assert Bag('foo') == ('foo', )
assert Bag() == ()
def test_eq_operator_dict():
assert Bag(foo='bar') == {'foo': 'bar'}
assert Bag(
foo='bar', corp='acme'
) == {
'foo': 'bar',
'corp': 'acme',
}
assert Bag(
foo='bar', corp='acme'
) == {
'corp': 'acme',
'foo': 'bar',
}
assert Bag() == {}
def test_repr(): def test_repr():
bag = Bag('a', a=1) bag = Bag('a', a=1)
assert repr(bag) == "<Bag ('a', a=1)>" assert repr(bag) == "<Bag ('a', a=1)>"

View File

@ -1,6 +1,9 @@
import functools
import io
import os import os
import runpy import runpy
import sys import sys
from contextlib import redirect_stdout, redirect_stderr
from unittest.mock import patch from unittest.mock import patch
import pkg_resources import pkg_resources
@ -10,12 +13,27 @@ from bonobo import __main__, __version__, get_examples_path
from bonobo.commands import entrypoint from bonobo.commands import entrypoint
def runner_entrypoint(*args): def runner(f):
@functools.wraps(f)
def wrapped_runner(*args):
with redirect_stdout(io.StringIO()) as stdout, redirect_stderr(io.StringIO()) as stderr:
try:
f(list(args))
except BaseException as exc:
return stdout.getvalue(), stderr.getvalue(), exc
return stdout.getvalue(), stderr.getvalue()
return wrapped_runner
@runner
def runner_entrypoint(args):
""" Run bonobo using the python command entrypoint directly (bonobo.commands.entrypoint). """ """ Run bonobo using the python command entrypoint directly (bonobo.commands.entrypoint). """
return entrypoint(list(args)) return entrypoint(args)
def runner_module(*args): @runner
def runner_module(args):
""" Run bonobo using the bonobo.__main__ file, which is equivalent as doing "python -m bonobo ...".""" """ Run bonobo using the bonobo.__main__ file, which is equivalent as doing "python -m bonobo ..."."""
with patch.object(sys, 'argv', ['bonobo', *args]): with patch.object(sys, 'argv', ['bonobo', *args]):
return runpy.run_path(__main__.__file__, run_name='__main__') return runpy.run_path(__main__.__file__, run_name='__main__')
@ -40,17 +58,15 @@ def test_entrypoint():
@all_runners @all_runners
def test_no_command(runner, capsys): def test_no_command(runner):
with pytest.raises(SystemExit): _, err, exc = runner()
runner() assert type(exc) == SystemExit
_, err = capsys.readouterr()
assert 'error: the following arguments are required: command' in err assert 'error: the following arguments are required: command' in err
@all_runners @all_runners
def test_run(runner, capsys): def test_run(runner):
runner('run', '--quiet', get_examples_path('types/strings.py')) out, err = runner('run', '--quiet', get_examples_path('types/strings.py'))
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0].startswith('Foo ') assert out[0].startswith('Foo ')
assert out[1].startswith('Bar ') assert out[1].startswith('Bar ')
@ -58,9 +74,8 @@ def test_run(runner, capsys):
@all_runners @all_runners
def test_run_module(runner, capsys): def test_run_module(runner):
runner('run', '--quiet', '-m', 'bonobo.examples.types.strings') out, err = runner('run', '--quiet', '-m', 'bonobo.examples.types.strings')
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0].startswith('Foo ') assert out[0].startswith('Foo ')
assert out[1].startswith('Bar ') assert out[1].startswith('Bar ')
@ -68,9 +83,8 @@ def test_run_module(runner, capsys):
@all_runners @all_runners
def test_run_path(runner, capsys): def test_run_path(runner):
runner('run', '--quiet', get_examples_path('types')) out, err = runner('run', '--quiet', get_examples_path('types'))
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0].startswith('Foo ') assert out[0].startswith('Foo ')
assert out[1].startswith('Bar ') assert out[1].startswith('Bar ')
@ -94,9 +108,8 @@ def test_install_requirements_for_file(runner):
@all_runners @all_runners
def test_version(runner, capsys): def test_version(runner):
runner('version') out, err = runner('version')
out, err = capsys.readouterr()
out = out.strip() out = out.strip()
assert out.startswith('bonobo ') assert out.startswith('bonobo ')
assert __version__ in out assert __version__ in out
@ -104,48 +117,47 @@ def test_version(runner, capsys):
@all_runners @all_runners
class TestDefaultEnvFile(object): class TestDefaultEnvFile(object):
def test_run_file_with_default_env_file(self, runner, capsys): def test_run_file_with_default_env_file(self, runner):
runner( out, err = runner(
'run', '--quiet', '--default-env-file', '.env_one', 'run', '--quiet', '--default-env-file', '.env_one',
get_examples_path('environment/env_files/get_passed_env_file.py') get_examples_path('environment/env_files/get_passed_env_file.py')
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'sweetpassword' assert out[1] == 'sweetpassword'
assert out[2] != 'marzo' assert out[2] != 'marzo'
def test_run_file_with_multiple_default_env_files(self, runner, capsys): def test_run_file_with_multiple_default_env_files(self, runner):
runner( out, err = runner(
'run', '--quiet', '--default-env-file', '.env_one', 'run', '--quiet', '--default-env-file', '.env_one', '--default-env-file', '.env_two',
'--default-env-file', '.env_two',
get_examples_path('environment/env_files/get_passed_env_file.py') get_examples_path('environment/env_files/get_passed_env_file.py')
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'sweetpassword' assert out[1] == 'sweetpassword'
assert out[2] != 'marzo' assert out[2] != 'marzo'
def test_run_module_with_default_env_file(self, runner, capsys): def test_run_module_with_default_env_file(self, runner):
runner( out, err = runner(
'run', '--quiet', '-m', 'run', '--quiet', '-m', 'bonobo.examples.environment.env_files.get_passed_env_file', '--default-env-file',
'bonobo.examples.environment.env_files.get_passed_env_file', '.env_one'
'--default-env-file', '.env_one'
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'sweetpassword' assert out[1] == 'sweetpassword'
assert out[2] != 'marzo' assert out[2] != 'marzo'
def test_run_module_with_multiple_default_env_files(self, runner, capsys): def test_run_module_with_multiple_default_env_files(self, runner):
runner( out, err = runner(
'run', '--quiet', '-m', 'run',
'--quiet',
'-m',
'bonobo.examples.environment.env_files.get_passed_env_file', 'bonobo.examples.environment.env_files.get_passed_env_file',
'--default-env-file', '.env_one', '--default-env-file', '.env_two', '--default-env-file',
'.env_one',
'--default-env-file',
'.env_two',
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'sweetpassword' assert out[1] == 'sweetpassword'
@ -154,49 +166,59 @@ class TestDefaultEnvFile(object):
@all_runners @all_runners
class TestEnvFile(object): class TestEnvFile(object):
def test_run_file_with_file(self, runner, capsys): def test_run_file_with_file(self, runner):
runner( out, err = runner(
'run', '--quiet', 'run',
'--quiet',
get_examples_path('environment/env_files/get_passed_env_file.py'), get_examples_path('environment/env_files/get_passed_env_file.py'),
'--env-file', '.env_one', '--env-file',
'.env_one',
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'sweetpassword' assert out[1] == 'sweetpassword'
assert out[2] == 'marzo' assert out[2] == 'marzo'
def test_run_file_with_multiple_files(self, runner, capsys): def test_run_file_with_multiple_files(self, runner):
runner( out, err = runner(
'run', '--quiet', 'run',
'--quiet',
get_examples_path('environment/env_files/get_passed_env_file.py'), get_examples_path('environment/env_files/get_passed_env_file.py'),
'--env-file', '.env_one', '--env-file', '.env_two', '--env-file',
'.env_one',
'--env-file',
'.env_two',
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'not_sweet_password' assert out[1] == 'not_sweet_password'
assert out[2] == 'abril' assert out[2] == 'abril'
def test_run_module_with_file(self, runner, capsys): def test_run_module_with_file(self, runner):
runner( out, err = runner(
'run', '--quiet', '-m', 'run',
'--quiet',
'-m',
'bonobo.examples.environment.env_files.get_passed_env_file', 'bonobo.examples.environment.env_files.get_passed_env_file',
'--env-file', '.env_one', '--env-file',
'.env_one',
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'sweetpassword' assert out[1] == 'sweetpassword'
assert out[2] == 'marzo' assert out[2] == 'marzo'
def test_run_module_with_multiple_files(self, runner, capsys): def test_run_module_with_multiple_files(self, runner):
runner( out, err = runner(
'run', '--quiet', '-m', 'run',
'--quiet',
'-m',
'bonobo.examples.environment.env_files.get_passed_env_file', 'bonobo.examples.environment.env_files.get_passed_env_file',
'--env-file', '.env_one', '--env-file', '.env_two', '--env-file',
'.env_one',
'--env-file',
'.env_two',
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'not_sweet_password' assert out[1] == 'not_sweet_password'
@ -204,28 +226,36 @@ class TestEnvFile(object):
@all_runners @all_runners
class TestEnvFileCombinations(object): class TestEnvFileCombinations:
def test_run_file_with_default_env_file_and_env_file(self, runner, capsys): def test_run_file_with_default_env_file_and_env_file(self, runner):
runner( out, err = runner(
'run', '--quiet', 'run',
'--quiet',
get_examples_path('environment/env_files/get_passed_env_file.py'), get_examples_path('environment/env_files/get_passed_env_file.py'),
'--default-env-file', '.env_one', '--env-file', '.env_two', '--default-env-file',
'.env_one',
'--env-file',
'.env_two',
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '321' assert out[0] == '321'
assert out[1] == 'not_sweet_password' assert out[1] == 'not_sweet_password'
assert out[2] == 'abril' assert out[2] == 'abril'
def test_run_file_with_default_env_file_and_env_file_and_env_vars(self, runner, capsys): def test_run_file_with_default_env_file_and_env_file_and_env_vars(self, runner):
runner( out, err = runner(
'run', '--quiet', 'run',
'--quiet',
get_examples_path('environment/env_files/get_passed_env_file.py'), get_examples_path('environment/env_files/get_passed_env_file.py'),
'--default-env-file', '.env_one', '--env-file', '.env_two', '--default-env-file',
'--env', 'TEST_USER_PASSWORD=SWEETpassWORD', '--env', '.env_one',
'--env-file',
'.env_two',
'--env',
'TEST_USER_PASSWORD=SWEETpassWORD',
'--env',
'MY_SECRET=444', 'MY_SECRET=444',
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == '444' assert out[0] == '444'
assert out[1] == 'SWEETpassWORD' assert out[1] == 'SWEETpassWORD'
@ -233,54 +263,45 @@ class TestEnvFileCombinations(object):
@all_runners @all_runners
class TestDefaultEnvVars(object): class TestDefaultEnvVars:
def test_run_file_with_default_env_var(self, runner, capsys): def test_run_file_with_default_env_var(self, runner):
runner( out, err = runner(
'run', '--quiet', 'run', '--quiet',
get_examples_path('environment/env_vars/get_passed_env.py'), get_examples_path('environment/env_vars/get_passed_env.py'), '--default-env', 'USER=clowncity', '--env',
'--default-env', 'USER=clowncity', '--env', 'USER=ted' 'USER=ted'
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == 'user' assert out[0] == 'user'
assert out[1] == 'number' assert out[1] == 'number'
assert out[2] == 'string' assert out[2] == 'string'
assert out[3] != 'clowncity' assert out[3] != 'clowncity'
def test_run_file_with_default_env_vars(self, runner, capsys): def test_run_file_with_default_env_vars(self, runner):
runner( out, err = runner(
'run', '--quiet', 'run', '--quiet',
get_examples_path('environment/env_vars/get_passed_env.py'), get_examples_path('environment/env_vars/get_passed_env.py'), '--env', 'ENV_TEST_NUMBER=123', '--env',
'--env', 'ENV_TEST_NUMBER=123', '--env', 'ENV_TEST_USER=cwandrews', 'ENV_TEST_USER=cwandrews', '--default-env', "ENV_TEST_STRING='my_test_string'"
'--default-env', "ENV_TEST_STRING='my_test_string'"
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == 'cwandrews' assert out[0] == 'cwandrews'
assert out[1] == '123' assert out[1] == '123'
assert out[2] == 'my_test_string' assert out[2] == 'my_test_string'
def test_run_module_with_default_env_var(self, runner, capsys): def test_run_module_with_default_env_var(self, runner):
runner( out, err = runner(
'run', '--quiet', '-m', 'run', '--quiet', '-m', 'bonobo.examples.environment.env_vars.get_passed_env', '--env',
'bonobo.examples.environment.env_vars.get_passed_env', 'ENV_TEST_NUMBER=123', '--default-env', 'ENV_TEST_STRING=string'
'--env', 'ENV_TEST_NUMBER=123',
'--default-env', 'ENV_TEST_STRING=string'
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == 'cwandrews' assert out[0] == 'cwandrews'
assert out[1] == '123' assert out[1] == '123'
assert out[2] != 'string' assert out[2] != 'string'
def test_run_module_with_default_env_vars(self, runner, capsys): def test_run_module_with_default_env_vars(self, runner):
runner( out, err = runner(
'run', '--quiet', '-m', 'run', '--quiet', '-m', 'bonobo.examples.environment.env_vars.get_passed_env', '--env',
'bonobo.examples.environment.env_vars.get_passed_env', 'ENV_TEST_NUMBER=123', '--env', 'ENV_TEST_USER=cwandrews', '--default-env', "ENV_TEST_STRING='string'"
'--env', 'ENV_TEST_NUMBER=123', '--env', 'ENV_TEST_USER=cwandrews',
'--default-env', "ENV_TEST_STRING='string'"
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == 'cwandrews' assert out[0] == 'cwandrews'
assert out[1] == '123' assert out[1] == '123'
@ -288,52 +309,43 @@ class TestDefaultEnvVars(object):
@all_runners @all_runners
class TestEnvVars(object): class TestEnvVars:
def test_run_file_with_env_var(self, runner, capsys): def test_run_file_with_env_var(self, runner):
runner( out, err = runner(
'run', '--quiet', 'run', '--quiet',
get_examples_path('environment/env_vars/get_passed_env.py'), get_examples_path('environment/env_vars/get_passed_env.py'), '--env', 'ENV_TEST_NUMBER=123'
'--env', 'ENV_TEST_NUMBER=123'
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] != 'test_user' assert out[0] != 'test_user'
assert out[1] == '123' assert out[1] == '123'
assert out[2] == 'my_test_string' assert out[2] == 'my_test_string'
def test_run_file_with_env_vars(self, runner, capsys): def test_run_file_with_env_vars(self, runner):
runner( out, err = runner(
'run', '--quiet', 'run', '--quiet',
get_examples_path('environment/env_vars/get_passed_env.py'), get_examples_path('environment/env_vars/get_passed_env.py'), '--env', 'ENV_TEST_NUMBER=123', '--env',
'--env', 'ENV_TEST_NUMBER=123', '--env', 'ENV_TEST_USER=cwandrews', 'ENV_TEST_USER=cwandrews', '--env', "ENV_TEST_STRING='my_test_string'"
'--env', "ENV_TEST_STRING='my_test_string'"
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == 'cwandrews' assert out[0] == 'cwandrews'
assert out[1] == '123' assert out[1] == '123'
assert out[2] == 'my_test_string' assert out[2] == 'my_test_string'
def test_run_module_with_env_var(self, runner, capsys): def test_run_module_with_env_var(self, runner):
runner( out, err = runner(
'run', '--quiet', '-m', 'run', '--quiet', '-m', 'bonobo.examples.environment.env_vars.get_passed_env', '--env',
'bonobo.examples.environment.env_vars.get_passed_env', 'ENV_TEST_NUMBER=123'
'--env', 'ENV_TEST_NUMBER=123'
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == 'cwandrews' assert out[0] == 'cwandrews'
assert out[1] == '123' assert out[1] == '123'
assert out[2] == 'my_test_string' assert out[2] == 'my_test_string'
def test_run_module_with_env_vars(self, runner, capsys): def test_run_module_with_env_vars(self, runner):
runner( out, err = runner(
'run', '--quiet', '-m', 'run', '--quiet', '-m', 'bonobo.examples.environment.env_vars.get_passed_env', '--env',
'bonobo.examples.environment.env_vars.get_passed_env', 'ENV_TEST_NUMBER=123', '--env', 'ENV_TEST_USER=cwandrews', '--env', "ENV_TEST_STRING='my_test_string'"
'--env', 'ENV_TEST_NUMBER=123', '--env', 'ENV_TEST_USER=cwandrews',
'--env', "ENV_TEST_STRING='my_test_string'"
) )
out, err = capsys.readouterr()
out = out.split('\n') out = out.split('\n')
assert out[0] == 'cwandrews' assert out[0] == 'cwandrews'
assert out[1] == '123' assert out[1] == '123'

View File

@ -0,0 +1,30 @@
from bonobo.util import sortedlist, ensure_tuple
from bonobo.util.collections import tuplize
def test_sortedlist():
l = sortedlist()
l.insort(2)
l.insort(1)
l.insort(3)
l.insort(2)
assert l == [1, 2, 2, 3]
def test_ensure_tuple():
assert ensure_tuple('a') == ('a', )
assert ensure_tuple(('a', )) == ('a', )
assert ensure_tuple(()) is ()
def test_tuplize():
tuplized_lambda = tuplize(lambda: [1, 2, 3])
assert tuplized_lambda() == (1, 2, 3)
@tuplize
def some_generator():
yield 'c'
yield 'b'
yield 'a'
assert some_generator() == ('c', 'b', 'a')

View File

@ -1,22 +0,0 @@
import types
from bonobo.util.iterators import force_iterator
def test_force_iterator_with_string():
assert force_iterator('foo') == ['foo']
def test_force_iterator_with_none():
assert force_iterator(None) == []
def test_force_iterator_with_generator():
def generator():
yield 'aaa'
yield 'bbb'
yield 'ccc'
iterator = force_iterator(generator())
assert isinstance(iterator, types.GeneratorType)
assert list(iterator) == ['aaa', 'bbb', 'ccc']

View File

@ -2,7 +2,7 @@ import operator
import pytest import pytest
from bonobo.util.objects import Wrapper, get_name, ValueHolder from bonobo.util.objects import Wrapper, get_name, ValueHolder, get_attribute_or_create
from bonobo.util.testing import optional_contextmanager from bonobo.util.testing import optional_contextmanager
@ -59,6 +59,73 @@ def test_valueholder():
assert repr(x) == repr(y) == repr(43) assert repr(x) == repr(y) == repr(43)
def test_valueholder_notequal():
x = ValueHolder(42)
assert x != 41
assert not (x != 42)
@pytest.mark.parametrize('rlo,rhi', [
(1, 2),
('a', 'b'),
])
def test_valueholder_ordering(rlo, rhi):
vlo, vhi = ValueHolder(rlo), ValueHolder(rhi)
for lo in (rlo, vlo):
for hi in (rhi, vhi):
assert lo < hi
assert hi > lo
assert lo <= lo
assert not (lo < lo)
assert lo >= lo
def test_valueholder_negpos():
neg, zero, pos = ValueHolder(-1), ValueHolder(0), ValueHolder(1)
assert -neg == pos
assert -pos == neg
assert -zero == zero
assert +pos == pos
assert +neg == neg
def test_valueholders_containers():
x = ValueHolder({1, 2, 3, 5, 8, 13})
assert 5 in x
assert 42 not in x
y = ValueHolder({'foo': 'bar', 'corp': 'acme'})
assert 'foo' in y
assert y['foo'] == 'bar'
with pytest.raises(KeyError):
y['no']
y['no'] = 'oh, wait'
assert 'no' in y
assert 'oh, wait' == y['no']
def test_get_attribute_or_create():
class X:
pass
x = X()
with pytest.raises(AttributeError):
x.foo
foo = get_attribute_or_create(x, 'foo', 'bar')
assert foo == 'bar'
assert x.foo == 'bar'
foo = get_attribute_or_create(x, 'foo', 'baz')
assert foo == 'bar'
assert x.foo == 'bar'
unsupported_operations = { unsupported_operations = {
int: {operator.matmul}, int: {operator.matmul},
str: { str: {

View File

@ -0,0 +1,18 @@
import bonobo
from bonobo.util.resolvers import _parse_option, _resolve_options, _resolve_transformations
def test_parse_option():
assert _parse_option('foo=bar') == ('foo', 'bar')
assert _parse_option('foo="bar"') == ('foo', 'bar')
assert _parse_option('sep=";"') == ('sep', ';')
assert _parse_option('foo') == ('foo', True)
def test_resolve_options():
assert _resolve_options(('foo=bar', 'bar="baz"')) == {'foo': 'bar', 'bar': 'baz'}
assert _resolve_options() == {}
def test_resolve_transformations():
assert _resolve_transformations(('PrettyPrinter', )) == (bonobo.PrettyPrinter, )