JSONBender is an embedded Python DSL for transforming dicts. It's name is inspired by Nickelodeon's cartoon series Avatar: The Last Airbender.
pip install JSONBender
JSONBender works by calling the bend()
function with a mapping and the source dict
as arguments. It raises a BendingException
if anyting bad happens during the transformation phase.
The mapping itself is a dict whose values are benders, i.e. objects that represent the transformations to be done to the source dict. Ex:
import json
from jsonbender import bend, K, S
MAPPING = {
'fullName': (S('customer', 'first_name') +
K(' ') +
S('customer', 'last_name')),
'city': S('address', 'city'),
}
source = {
'customer': {
'first_name': 'Inigo',
'last_name': 'Montoya',
'Age': 24,
},
'address': {
'city': 'Sicily',
'country': 'Florin',
},
}
result = bend(MAPPING, source)
print(json.dumps(result))
{"city": "Sicily", "fullName": "Inigo Montoya"}
K()
is a selector for constant values:
It takes any value as a parameter and always returns that value regardless of the input.
S()
is a selector for accessing keys and indices: It takes a variable number of keys / indices and returns the corresponding value on the source dict:
from jsonbender import bend, S
MAPPING = {'val': S('a', 'deeply', 'nested', 0, 'value')}
ret = bend(MAPPING, {'a': {'deeply': {'nested': [{'value': 42}]}}})
assert ret == {'val': 42}
If any of keys may not exist, S()
can be "annotated" by calling the .optional(default)
method, which returns an instance of OptionalS
.
.optional()
takes a single parameter which is passed as the default
value of OptionalS
; it defaults to None
.
OptionalS
is like S()
but does not raise errors when any of the keys is not found. Instead, it returns None
or the default
value that is passed on its construction.
from jsonbender import bend, OptionalS
source = {'does': {'exist': 23}}
MAPPING_1 = {'val': OptionalS('does', 'not', 'exist')}
ret = bend(MAPPING_1, source)
assert ret == {'val': None}
MAPPING_2 = {'val': OptionalS('does', 'not', 'exist', default=27)}
ret = bend(MAPPING_2, source)
assert ret == {'val': 27}
For readability and reusability, prefer using S().optional()
instead.
F()
lifts a python callable into a Bender, so it can be called at bending time.
It is useful for performing more complex operations for which actual python code is necessary.
The extra optional args and kwargs are passed to the function at bending time after the given value.
from jsonbender import bend, F, S
MAPPING = {
'total_number_of_keys': F(len),
'number_of_str_keys': F(lambda source: len([k for k in source.keys()
if isinstance(k, str)])),
'price_truncated': S('price_as_str') >> F(float) >> F(int),
}
ret = bend(MAPPING, {'price_as_str': '42.2', 'k1': 'v', 1: 'a'})
assert ret == {'price_truncated': 42,
'total_number_of_keys': 3,
'number_of_str_keys': 2}
If the function can't take certain values, you can protect it by calling the .protect()
method.
import math
from jsonbender import bend, F, S
MAPPING_1 = {'sqrt': S('val') >> F(math.sqrt).protect()}
assert bend(MAPPING_1, {'val': 4}) == {'sqrt': 2}
assert bend(MAPPING_1, {'val': None}) == {'sqrt': None}
MAPPING_2 = {'sqrt': S('val') >> F(math.sqrt).protect(-1)}
assert bend(MAPPING_2, {'val': -1}) == {'sqrt': -1}
Benders implement most of python's binary operators.
For the arithmetic +
, -
, *
, /
,
the behavior is to apply the operator to the bended values of each operand.
from jsonbender import bend, K, S
a = S('a')
b = S('b')
MAPPING = {'add': a + b, 'sub': a - b, 'mul': a * b, 'div': a / b}
ret = bend(MAPPING, {'a': 10, 'b': 5})
assert ret == {'add': 15, 'sub': 5, 'mul': 50, 'div': 2}
ret = bend({'full_name': S('first_name') + K(' ') + S('last_name')},
{'first_name': 'John', 'last_name': 'Doe'})
assert ret == {'full_name': 'John Doe'}
The bitwise operators are not yet implemented, except for the lshift (<<
) and rshift (>>
).
See "Composition" below.
There are 4 benders for working with lists, inspired by the common functional programming operations.
Similar to Python's reduce()
.
Reduces an iterable into a single value by repeatedly applying the given
function to the elements.
The function must accept two parameters: the first is the accumulator (the
value returned from the last call), which defaults to the first element of
the iterable (it must be nonempty); the second is the next value from the
iterable.
from jsonbender import bend, Reduce, S
MAPPING = {'sum': S('ints') >> Reduce(lambda acc, i: acc + i)}
ret = bend(MAPPING, {'ints': [1, 4, 7, 9]})
assert ret == {'sum': 21}
Similar to Python's filter()
.
Builds a new list with the elements of the iterable for which the given
function returns True.
from jsonbender import bend, Filter, S
MAPPING = {'even': S('ints') >> Filter(lambda i: i % 2 == 0)}
ret = bend(MAPPING, {'ints': range(5)})
assert ret == {'even': [0, 2, 4]}
Similar to Python's map()
.
Builds a new list by applying the given function to each element of the
iterable.
from jsonbender import bend, Forall, S
MAPPING = {'doubles': S('ints') >> Forall(lambda i: i * 2)}
ret = bend(MAPPING, {'ints': range(5)})
assert ret == {'doubles': [0, 2, 4, 6, 8]}
For the common case of applying a JSONBender mapping to each element of a list,
the .bend()
class method is provided, which returns a ForallBend
instance
. .bend()
takes the mapping and the context (optional) which are then passed
to ForallBend
.
Bends each element of the list with given mapping and context.
If no context is passed, it "inherits" at bend-time the context passed to the outer bend()
call.
from jsonbender import bend, S
from jsonbender.list_ops import ForallBend
MAPPING = {'list_of_bs': S('list_of_as') >> ForallBend({'b': S('a')})}
source = {'list_of_as': [{'a': 23}, {'a': 27}]}
ret = bend(MAPPING, source)
assert ret == {'list_of_bs': [{'b': 23}, {'b': 27}]}
Similar to Forall, but the given function must return an iterable for each element of the iterable, which are than "flattened" into a single list.
from jsonbender import bend, S
from jsonbender.list_ops import FlatForall
MAPPING = {'doubles_triples': S('ints') >> FlatForall(lambda x: [x * 2, x * 3])}
source = {'ints': [2, 15, 50]}
ret = bend(MAPPING, source)
assert ret == {'doubles_triples': [4, 6, 30, 45, 100, 150]}
Sometimes what bender to use must be decided at bending time, so JSONBender provides 3 control flow structures:
Take any number of benders, and return the value of the first one that doesn't raise a LookupError (KeyError, IndexError etc.).
If all benders raise LookupError, re-raise the last raised exception.
from jsonbender import S
from jsonbender.control_flow import Alternation
b = Alternation(S(1), S(0), S('key1'))
b(['a', 'b']) # -> 'b'
b(['a']) # -> 'a'
try:
b([]) # -> TypeError
except TypeError:
pass
try:
b({}) # -> KeyError
except KeyError:
pass
b({'key1': 23}) # -> 23
Takes a condition bender, and two benders (both default to K(None)). If the condition bender evaluates to true, return the value of the first bender. If it evaluates to false, return the value of the second bender.
from jsonbender import K, S
from jsonbender.control_flow import If
if_ = If(S('country') == K('China'), S('first_name'), S('last_name'))
if_({'country': 'China',
'first_name': 'Li',
'last_name': 'Na'}) # -> 'Li'
if_({'country': 'Brazil',
'first_name': 'Gustavo',
'last_name': 'Kuerten'}) # -> 'Kuerten'
Take a key bender, a 'case' container of benders and a default bender (optional).
The value returned by the key bender is used to get a bender from the case container, which then returns the result.
If the key is not in the case container, the default is used.
If it's unavailable, raise the original LookupError.
from jsonbender import K, S
from jsonbender.control_flow import Switch
b = Switch(S('service'),
{'twitter': S('handle'),
'mastodon': S('handle') + K('@') + S('server')},
default=S('email'))
b({'service': 'twitter', 'handle': 'etandel'}) # -> 'etandel'
b({'service': 'mastodon', 'handle': 'etandel',
'server': 'mastodon.social'}) # -> 'etandel@mastodon.social'
b({'service': 'facebook',
'email': 'email@whatever.com'}) # -> 'email@whatever.com'
JSONBender currently provides only one string-related bender.
Return a formatted string just like str.format()
.
Where the values to be formatted are given by benders as positional or
named parameters.
It uses the same syntax as str.format()
from jsonbender import bend, Format, S
MAPPING = {'formatted': Format('{} {} {last}',
S('first'),
S('second'),
last=S('last'))}
source = {'first': 'Edsger', 'second': 'W.', 'last': 'Dijkstra'}
ret = bend(MAPPING, source)
assert ret == {'formatted': 'Edsger W. Dijkstra'}
All JSONBenders can be composed with other benders using <<
and >>
to make them receive previously bended values.
from jsonbender import bend, F, S
from jsonbender.list_ops import Forall
MAPPING = {
'name': S('name'),
'pythonista': S('prog_langs') >> Forall(str.lower) >> F(lambda ls: 'python' in ls),
}
source = {
'name': 'Mary',
'prog_langs': ['C', 'Python', 'Lua'],
}
ret = bend(MAPPING, source)
assert ret == {'name': 'Mary', 'pythonista': True}
Sometimes it's necessary to use values at bending time that are not on the
source json and are not known at mapping time.
For these cases there is the optional context
argument to bend()
function.
Whatever you pass for the argument is can be used at bending time by the
Context()
bender.
from jsonbender import bend, Context, S
MAPPING = {
'name': S('name'),
'age': (Context() >> S('year')) - S('birthyear'),
}
source = {'name': 'Mary', 'birthyear': 1990}
ret = bend(MAPPING, source, context={'year': 2016})
assert ret == {'name': 'Mary', 'age': 26}