Source code for fictive.transform

"""
transform (i.e., function) objects supporting inverse, composition, & mapping

"""

import functools
import itertools
import types

from fictive.patterns.dynamic_load import LazyDescriptor


__all__ = (
    'compose',
    'pack',
    'arg_slice',
    'Transform',
)


[docs]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, tuple): args = (args, ) args = function(*args) return args return composition
[docs]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
[docs]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
[docs]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')
[docs] 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`')
[docs] def operation(self, *args): """ Transform from `args` to output """ raise NotImplementedError()
[docs] def inverse_operation(self, *args): """ Reverse transform from `args` to original input """ raise NotImplementedError()
[docs] def __call__(self, *args): """ calling the transform applies the `.operation` """ return self.operation(*args)
[docs] @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), )
[docs] @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))), )
[docs] 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)