Source code for fictive.transform

"""
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)