File: curry.py

package info (click to toggle)
python-returns 0.26.0-2
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 2,652 kB
  • sloc: python: 11,000; makefile: 18
file content (190 lines) | stat: -rw-r--r-- 6,466 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
from collections.abc import Callable
from functools import partial as _partial
from functools import wraps
from inspect import BoundArguments, Signature
from typing import Any, TypeAlias, TypeVar

_ReturnType = TypeVar('_ReturnType')


def partial(
    func: Callable[..., _ReturnType],
    *args: Any,
    **kwargs: Any,
) -> Callable[..., _ReturnType]:
    """
    Typed partial application.

    It is just a ``functools.partial`` wrapper with better typing support.

    We use a custom ``mypy`` plugin to make sure types are correct.
    Otherwise, it is currently impossible to properly type this function.

    .. code:: python

      >>> from returns.curry import partial

      >>> def sum_two_numbers(first: int, second: int) -> int:
      ...     return first + second

      >>> sum_with_ten = partial(sum_two_numbers, 10)
      >>> assert sum_with_ten(2) == 12
      >>> assert sum_with_ten(-5) == 5

    See also:
        - https://docs.python.org/3/library/functools.html#functools.partial

    """
    return _partial(func, *args, **kwargs)


def curry(function: Callable[..., _ReturnType]) -> Callable[..., _ReturnType]:
    """
    Typed currying decorator.

    Currying is a conception from functional languages that does partial
    applying. That means that if we pass one argument in a function that
    gets 2 or more arguments, we'll get a new function that remembers all
    previously passed arguments. Then we can pass remaining arguments, and
    the function will be executed.

    :func:`~partial` function does a similar thing,
    but it does partial application exactly once.
    ``curry`` is a bit smarter and will do partial
    application until enough arguments passed.

    If wrong arguments are passed, ``TypeError`` will be raised immediately.

    We use a custom ``mypy`` plugin to make sure types are correct.
    Otherwise, it is currently impossible to properly type this function.

    .. code:: pycon

      >>> from returns.curry import curry

      >>> @curry
      ... def divide(number: int, by: int) -> float:
      ...     return number / by

      >>> divide(1)  # doesn't call the func and remembers arguments
      <function divide at ...>
      >>> assert divide(1)(by=10) == 0.1  # calls the func when possible
      >>> assert divide(1)(10) == 0.1  # calls the func when possible
      >>> assert divide(1, by=10) == 0.1  # or call the func like always

    Here are several examples with wrong arguments:

    .. code:: pycon

      >>> divide(1, 2, 3)
      Traceback (most recent call last):
        ...
      TypeError: too many positional arguments

      >>> divide(a=1)
      Traceback (most recent call last):
        ...
      TypeError: got an unexpected keyword argument 'a'

    Limitations:

    - It is kinda slow. Like 100 times slower than a regular function call.
    - It does not work with several builtins like ``str``, ``int``,
      and possibly other ``C`` defined callables
    - ``*args`` and ``**kwargs`` are not supported
      and we use ``Any`` as a fallback
    - Support of arguments with default values is very limited,
      because we cannot be totally sure which case we are using:
      with the default value or without it, be careful
    - We use a custom ``mypy`` plugin to make types correct,
      otherwise, it is currently impossible
    - It might not work as expected with curried ``Klass().method``,
      it might generate invalid method signature
      (looks like a bug in ``mypy``)
    - It is probably a bad idea to ``curry`` a function with lots of arguments,
      because you will end up with lots of overload functions,
      that you won't be able to understand.
      It might also be slow during the typecheck
    - Currying of ``__init__`` does not work because of the bug in ``mypy``:
      https://github.com/python/mypy/issues/8801

    We expect people to use this tool responsibly
    when they know that they are doing.

    See also:
    - https://en.wikipedia.org/wiki/Currying
    - https://stackoverflow.com/questions/218025/

    """
    argspec = Signature.from_callable(function).bind_partial()

    def decorator(*args, **kwargs):
        return _eager_curry(function, argspec, args, kwargs)

    return wraps(function)(decorator)


def _eager_curry(
    function: Callable[..., _ReturnType],
    argspec,
    args: tuple,
    kwargs: dict,
) -> _ReturnType | Callable[..., _ReturnType]:
    """
    Internal ``curry`` implementation.

    The interesting part about it is that it return the result
    or a new callable that will return a result at some point.
    """
    intermediate, full_args = _intermediate_argspec(argspec, args, kwargs)
    if full_args is not None:
        return function(*full_args[0], **full_args[1])

    # We use closures to avoid names conflict between
    # the function args and args of the curry implementation.
    def decorator(*inner_args, **inner_kwargs):
        return _eager_curry(function, intermediate, inner_args, inner_kwargs)

    return wraps(function)(decorator)


_ArgSpec: TypeAlias = (
    # Case when all arguments are bound and function can be called:
    tuple[None, tuple[tuple, dict]]
    |
    # Case when there are still unbound arguments:
    tuple[BoundArguments, None]
)


def _intermediate_argspec(
    argspec: BoundArguments,
    args: tuple,
    kwargs: dict,
) -> _ArgSpec:
    """
    That's where ``curry`` magic happens.

    We use ``Signature`` objects from ``inspect`` to bind existing arguments.

    If there's a ``TypeError`` while we ``bind`` the arguments we try again.
    The second time we try to ``bind_partial`` arguments. It can fail too!
    It fails when there are invalid arguments
    or more arguments than we can fit in a function.

    This function is slow. Any optimization ideas are welcome!
    """
    full_args = argspec.args + args
    full_kwargs = {**argspec.kwargs, **kwargs}

    try:
        argspec.signature.bind(*full_args, **full_kwargs)
    except TypeError:
        # Another option is to copy-paste and patch `getcallargs` func
        # but in this case we get responsibility to maintain it over
        # python releases.
        # This place is also responsible for raising ``TypeError`` for cases:
        # 1. When incorrect argument is provided
        # 2. When too many arguments are provided
        return argspec.signature.bind_partial(*full_args, **full_kwargs), None
    return None, (full_args, full_kwargs)