"""
transform (i.e., function) objects supporting inverse, composition, & mapping
"""
import functools
import itertools
import types
import typing
from fictive.patterns.dynamic_load import LazyDescriptor
__all__ = (
'compose',
'pack',
'arg_slice',
'Transform',
)
def compose(*functions):
"""
creates a function that is the composition of `functions`
NB: if a function's return value is a `tuple`, that `tuple` will
be used as the `var-positional` :term:`parameter` to the next
function in the composition. Otherwise, the entire return value
of a function is used as the first positional argument to the next
function.
.. testcode:: compose
import typing
def swap(left, right):
return right, left
def xor(base, variable):
return base, base ^ variable
def plus_one(iterable: typing.Iterable):
return (item + 1 for item in iterable)
def times_two(iterable: typing.Iterable):
return (item * 2 for item in iterable)
.. doctest:: compose
>>> from fictive.transform import compose
>>> # NB: functions are applied applied left-to-right
>>> compose(swap, xor)(2, 3)
(3, 1)
>>> compose(xor, swap)(2, 3)
(1, 2)
>>> # NB: if the return type of a function is not a `tuple`,
>>> # it will be provided as the first positional argument to
>>> # the next function
>>> compose(plus_one, times_two)([3, 1])
<generator object times_two.<locals>.<genexpr> ...
>>> compose(plus_one, times_two, tuple)([3, 1])
(8, 4)
>>> # NB: if the return type of a function is a `tuple` (e.g.,
>>> # `swap`), that `tuple` will be unpacked for the next
>>> # function in the compositon
>>> compose(swap, plus_one, tuple)(2, 3)
Traceback (most recent call last):
...
TypeError: plus_one() takes 1 positional argument but 2 were given
>>> # adding an explicit packing function to the composition
>>> # can "bridge" between functions that return `tuple`\\s
>>> # and functions that expect e.g. a single iterable
>>> compose(swap, lambda *args: (args, ), plus_one, tuple)(2, 3)
(4, 3)
"""
def composition(*args):
for function in functions:
if not isinstance(args, typing.Tuple):
args = (args, )
args = function(*args)
return args
return composition
def pack(*args, **kwargs):
"""
pack the positional and keyword :term:`argument`\\s to a :code:`(args, kwargs)` `tuple`
.. doctest:: pack
>>> from fictive.transform import pack
>>> pack(1, 2, 3, four=4, five=5) == ((1, 2, 3), {'four': 4, 'five': 5})
True
"""
return args, kwargs
def arg_slice(*args):
"""
perform a `slice` operation on `args` and return the sliced tuple
.. doctest:: arg_slice
>>> from fictive.transform import arg_slice
>>> arg_slice(2, 5)(1, 2, 3, 4, 5, 6, 7)
(3, 4, 5)
"""
if len(args) == 1 and isinstance(args[0], slice):
slice_object = args[0]
else:
slice_object = slice(*args)
def arg_slice(*args): # pylint: disable=redefined-outer-name
return args[slice_object]
return arg_slice
class Transform(object):
"""
interface for inverse, composition, and mapping operations on functions
Subclassing:
.. testcode:: transform
from fictive.transform import Transform
class AddN(Transform):
def __init__(self, n):
super().__init__()
self.n = n
def operation(self, args):
return (arg + self.n for arg in args)
def inverse_operation(self, args):
return (arg - self.n for arg in args)
.. doctest:: transform
>>> add_5 = AddN(5)
>>> add_5(range(4))
<generator object AddN.operation...>
>>> list(add_5(range(4)))
[5, 6, 7, 8]
>>> list(add_5.inverse([5, 6, 7, 8]))
[0, 1, 2, 3]
Ad hoc transforms:
.. testcode:: transform
times_two = Transform(
#: unpacked positional arguments to iterator
method=lambda self, *args: (arg * self.base for arg in args),
#: iterator to tuple
inverse=lambda self, args: tuple(arg // self.base for arg in args),
)
times_two.base = 2
.. doctest:: transform
>>> times_two(range(4))
<generator object <lambda>...>
>>> list(times_two(range(4)))
Traceback (most recent call last):
...
TypeError: unsupported operand type(s) for *: 'range' and 'int'
>>> list(times_two(*range(4)))
[0, 2, 4, 6]
>>> times_two.inverse([0, 2, 4, 6])
(0, 1, 2, 3)
Applying `map` ahead of time:
.. testcode:: transform
int_to_str = Transform(str, inverse=int)
.. doctest:: transform
>>> multi = int_to_str.map
>>> list(multi([1, 2, 3]))
['1', '2', '3']
>>> list(multi.inverse(['1', '2', '3']))
[1, 2, 3]
Compopsition:
.. doctest:: transform
>>> transform = (times_two @ tuple @ int_to_str.map)
>>> list(transform([1, 2, 3]))
['11', '22', '33']
.. testcode:: transform
plus_one = Transform(
function=lambda arg: arg + 1,
inverse=lambda arg: arg - 1)
join_args = Transform(
method=lambda self, *args: self.token.join(args),
inverse=lambda self, arg: arg.split(self.token))
join_args.token = '.'
tuplefy = Transform(tuple, inverse=iter)
.. doctest:: transform
>>> transform = \\
... join_args @ tuplefy @ (int_to_str @ plus_one).map @ tuplefy.inverse
>>> transform([1, 2, 3])
'2.3.4'
>>> transform.inverse('2.3.4')
(1, 2, 3)
"""
BASE_TYPE = LazyDescriptor('fictive.transform:Transform')
def __init__(self, function=None, method=None, inverse=None):
"""
:param function:
set the transform's operation to a callable that **will
not** receive the transform itself as a bound parameter
(i.e., the transform instance **will not** be the `self`
parameter). Exclusive with `method`.
:param method:
set the transform's operation to a callable that **will**
receive the transform itself as a bound parameter (i.e.,
the transform instance **will* be the `self` parameter).
Exclusive with `function`.
:param inverse:
set the transform's inverse operation to the callable.
Will be treated as a "function" or a "method" (i.e.,
whether to provide the transform itself as the first bound
parameter) based on which of `function` or `method` is
also set. (Requires one of `function` or `method` to be
set.)
"""
if function and method:
raise ValueError('only specify one of `function` or `method`')
if function:
setattr(self, 'operation', function)
elif method:
setattr(self, 'operation', types.MethodType(method, self))
if inverse and function:
setattr(self, 'inverse_operation', inverse)
elif inverse and method:
setattr(self, 'inverse_operation', types.MethodType(inverse, self))
elif inverse:
raise ValueError('`inverse` requires one of `function` or `method`')
def operation(self, *args):
"""
Transform from `args` to output
"""
raise NotImplementedError()
def inverse_operation(self, *args):
"""
Reverse transform from `args` to original input
"""
raise NotImplementedError()
def __call__(self, *args):
"""
calling the transform applies the `.operation`
"""
return self.operation(*args)
@classmethod
def transpose(cls, transform):
"""
create a new transform, swapping the function and inverse
.. testcode:: transform_transpose
from fictive.transform import Transform
add_five = Transform(
function=lambda arg: arg + 5,
inverse=lambda arg: arg - 5,
)
.. doctest:: transform_transpose
>>> add_five(3)
8
>>> add_five.inverse_operation(8)
3
>>> subtract_five = Transform.transpose(add_five)
>>> subtract_five(8)
3
>>> subtract_five.inverse_operation(3)
8
"""
if not isinstance(transform, Transform):
raise NotImplementedError()
return cls(
function=transform.inverse_operation,
inverse=transform.operation,
)
@property
def inverse(self):
"""
return a transpose of `self`
.. testcode:: transform_inverse
from fictive.transform import Transform
add_five = Transform(
function=lambda arg: arg + 5,
inverse=lambda arg: arg - 5,
)
.. doctest:: transform_inverse
>>> subtract_five = add_five.inverse
>>> subtract_five(8)
3
>>> subtract_five.inverse_operation(3)
8
"""
return self.BASE_TYPE.transpose(self)
@property
def map(self):
"""
a new transform that will "map" the current transform when called
.. testcode:: transform_map
from fictive.transform import Transform
int_to_str = Transform(str, inverse=int)
.. doctest:: transform_map
>>> int_to_str(5)
'5'
>>> int_to_str_map = int_to_str.map
>>> int_to_str_map([1, 2, 3])
<map object at ...>
>>> list(int_to_str.map([1, 2, 3]))
['1', '2', '3']
>>> list(int_to_str.inverse.map(['1', '2', '3']))
[1, 2, 3]
"""
cls = self.BASE_TYPE
return cls(
function=functools.partial(map, self.operation),
inverse=functools.partial(map, self.inverse_operation),
)
@property
def starmap(self):
"""
a new transform that will "star map" the current transform
.. testcode:: transform_starmap
import operator
from fictive.transform import Transform
add = Transform(operator.add)
.. doctest:: transform_starmap
>>> add(3, 4)
7
>>> add([3, 4], [5, 6])
[3, 4, 5, 6]
>>> add.starmap(([3, 4], [5, 6]))
<itertools.starmap object at ...>
>>> list(add.starmap(([3, 4], [5, 6])))
[7, 11]
"""
cls = self.BASE_TYPE
return cls(
function=functools.partial(itertools.starmap, self.operation),
inverse=functools.partial(itertools.starmap, self.inverse_operation),
)
@classmethod
def compose(cls, *transforms):
"""
create a new transform that is the composition of `transforms`
NB: transforms are applied "left-to-right" (i.e., postfix)
.. doctest:: transform_compose
>>> import operator
>>> from fictive.transform import pack, Transform
>>> int_to_str = Transform(str, inverse=int)
>>> join = Transform(''.join)
>>> add = Transform(operator.add)
>>> sum_of_strs = Transform.compose(int_to_str.map, tuple, add)
>>> sum_of_strs([3, 4])
'34'
>>> str_of_sum = Transform.compose(tuple, add, int_to_str)
>>> str_of_sum([3, 4])
'7'
"""
transforms = tuple(
t if isinstance(t, Transform) else Transform(t) for t in transforms)
return cls(
function=compose(*(t.operation for t in transforms)),
inverse=compose(*(t.inverse_operation for t in reversed(transforms))),
)
def __matmul__(self, transform):
"""
use the "at" operator (i.e., :code:`@`) to compose the transform
NB: this operator uses the standard "infix" notation for
function composition, e.g. :code:`(f @ g)(x) == (f(g(x))`
.. doctest:: transform_matmul
>>> import operator
>>> from fictive.transform import pack, Transform
>>> int_to_str = Transform(str, inverse=int)
>>> join = Transform(''.join)
>>> add = Transform(operator.add)
>>> sum_of_strs = add @ tuple @ int_to_str.map
>>> sum_of_strs([3, 4])
'34'
>>> str_of_sum = int_to_str @ add @ tuple
>>> str_of_sum([3, 4])
'7'
"""
return self.BASE_TYPE.compose(transform, self)