Skip to content

Commit

Permalink
feat(#26): add curry function
Browse files Browse the repository at this point in the history
  • Loading branch information
h2non committed Dec 18, 2016
1 parent 108812b commit ff87bb6
Show file tree
Hide file tree
Showing 7 changed files with 346 additions and 1 deletion.
3 changes: 3 additions & 0 deletions paco/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from .race import race
from .once import once
from .wait import wait
from .curry import curry
from .wraps import wraps
from .apply import apply
from .defer import defer
Expand Down Expand Up @@ -33,12 +34,14 @@
# Current package version
__version__ = '0.1.6'

# Explicit symbols to export
__all__ = (
'ConcurrentExecutor',
'apply',
'compose',
'concurrent',
'constant',
'curry',
'defer',
'dropwhile',
'each',
Expand Down
33 changes: 33 additions & 0 deletions paco/assertions.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import asyncio
import inspect


def isiter(x):
Expand All @@ -15,6 +16,38 @@ def isiter(x):
return hasattr(x, '__iter__') and not isinstance(x, (str, bytes))


def iscallable(x):
"""
Returns `True` if the given value is a callable primitive object.
Arguments:
x (mixed): value to check.
Returns:
bool
"""
return any([
isfunc(x),
asyncio.iscoroutinefunction(x)
])


def isfunc(x):
"""
Returns `True` if the given value is a function or method object.
Arguments:
x (mixed): value to check.
Returns:
bool
"""
return any([
inspect.isfunction(x) and not asyncio.iscoroutinefunction(x),
inspect.ismethod(x) and not asyncio.iscoroutinefunction(x)
])


def iscoro_or_corofunc(x):
"""
Returns ``True`` if the given value is a coroutine or a coroutine function.
Expand Down
143 changes: 143 additions & 0 deletions paco/curry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
import inspect
import functools
from .wraps import wraps
from .assertions import isfunc, iscallable


def curry(arity_or_fn=None, ignore_kwargs=False, evaluator=None, *args, **kw):
"""
Creates a function that accepts one or more arguments of a function and
either invokes func returning its result if at least arity number of
arguments have been provided, or returns a function that accepts the
remaining function arguments until the function arity is satisfied.
This function is overloaded: you can pass a function or coroutine function
as first argument or an `int` indicating the explicit function arity.
Function arity can be inferred via function signature or explicitly
passed via `arity_or_fn` param.
You can optionally ignore keyword based arguments as well passsing the
`ignore_kwargs` param with `True` value.
This function can be used as decorator.
Arguments:
arity_or_fn (int|function|coroutinefunction): function arity to curry
or function to curry.
ignore_kwargs (bool): ignore keyword arguments as arity to satisfy
during curry.
evaluator (function): use a custom arity evaluator function.
*args (mixed): mixed variadic arguments for partial function
application.
*kwargs (mixed): keyword variadic arguments for partial function
application.
Raises:
TypeError: if function is not a function or a coroutine function.
Returns:
function or coroutinefunction: function will be returned until all the
function arity is satisfied, where a coroutine function will be
returned instead.
Usage::
# Function signature inferred function arity
@paco.curry
async def task(x, y, z=0):
return x * y + z
await task(4)(4)(z=8)
# => 24
# User defined function arity
@paco.curry(4)
async def task(x, y, *args, **kw):
return x * y + args[0] * args[1]
await task(4)(4)(8)(8)
# => 80
# Ignore keyword arguments from arity
@paco.curry(ignore_kwargs=True)
async def task(x, y, z=0):
return x * y
await task(4)(4)
# => 16
"""
def isvalidarg(x):
return all([
x.kind != x.VAR_KEYWORD,
x.kind != x.VAR_POSITIONAL,
any([
not ignore_kwargs,
ignore_kwargs and x.default == x.empty
])
])

def params(fn):
return inspect.signature(fn).parameters.values()

def infer_arity(fn):
return len([x for x in params(fn) if isvalidarg(x)])

def merge_args(acc, args, kw):
_args, _kw = acc
_args = _args + args
_kw = _kw or {}
_kw.update(kw)
return _args, _kw

def currier(arity, acc, fn, *args, **kw):
"""
Function either continues curring of the arguments
or executes function if desired arguments have being collected.
If function curried is variadic then execution without arguments
will finish curring and trigger the function
"""
# Merge call arguments with accumulated ones
_args, _kw = merge_args(acc, args, kw)

# Get current function call accumulated arity
current_arity = len(args)

# Count keyword params as arity to satisfy, if required
if not ignore_kwargs:
current_arity += len(kw)

# Decrease function arity to satisfy
arity -= current_arity

# Use user-defined custom arity evaluator strategy, if present
currify = evaluator and evaluator(acc, fn)

# If arity is not satisfied, return recursive partial function
if currify is not False and arity > 0:
return functools.partial(currier, arity, (_args, _kw), fn)

# If arity is satisfied, instanciate coroutine and return it
return fn(*_args, **_kw)

def wrapper(fn, *args, **kw):
if not iscallable(fn):
raise TypeError('first argument must a coroutine function, a '
'function or a method.')

# Infer function arity, if required
arity = (arity_or_fn if isinstance(arity_or_fn, int)
else infer_arity(fn))

# Wraps function as coroutine function, if needed.
fn = wraps(fn) if isfunc(fn) else fn

# Otherwise return recursive currier function
return currier(arity, (args, kw), fn, *args, **kw) if arity > 0 else fn

# Return currier function or decorator wrapper
return (wrapper(arity_or_fn, *args, **kw)
if iscallable(arity_or_fn)
else wrapper)
5 changes: 5 additions & 0 deletions paco/wraps.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,13 @@ def mul_2(num):
# => 4
"""
# If already a coroutine function, just return it
if asyncio.iscoroutinefunction(fn):
return fn

@functools.wraps(fn)
@asyncio.coroutine
def wrapper(*args, **kw):
return fn(*args, **kw)

return wrapper
33 changes: 32 additions & 1 deletion tests/assertions_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
import asyncio
from paco.assertions import (assert_corofunction,
assert_iter, isiter,
iscoro_or_corofunc)
iscoro_or_corofunc,
iscallable, isfunc)


def test_isiter():
Expand All @@ -14,6 +15,36 @@ def test_isiter():
assert not isiter(True)


def test_iscallable():
@asyncio.coroutine
def coro():
pass

assert iscallable(test_iscallable)
assert iscallable(lambda: True)
assert iscallable(coro)
assert not iscallable(tuple())
assert not iscallable([])
assert not iscallable('foo')
assert not iscallable(bytes())
assert not iscallable(True)


def test_isfunc():
@asyncio.coroutine
def coro():
pass

assert isfunc(test_isfunc)
assert isfunc(lambda: True)
assert not isfunc(coro)
assert not isfunc(tuple())
assert not isfunc([])
assert not isfunc('foo')
assert not isfunc(bytes())
assert not isfunc(True)


@asyncio.coroutine
def coro(*args, **kw):
return args, kw
Expand Down
118 changes: 118 additions & 0 deletions tests/curry_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
import asyncio
from paco import curry
from .helpers import run_in_loop


def task(x, y, baz=None, *args, **kw):
return x + y, baz, kw


@asyncio.coroutine
def coro(x, y, baz=None, *args, **kw):
return task(x, y, baz=baz, *args, **kw)


def test_curry_function_arity():
num, val, kw = run_in_loop(curry(task)(2)(4)(baz='foo'))
assert num == 6
assert val == 'foo'
assert kw == {}

num, val, kw = run_in_loop(curry(task)(2, 4)(baz='foo'))
assert num == 6
assert val == 'foo'
assert kw == {}

num, val, kw = run_in_loop(curry(task)(2, 4, baz='foo'))
assert num == 6
assert val == 'foo'
assert kw == {}

num, val, kw = run_in_loop(curry(task)(2, 4, baz='foo', fee=True))
assert num == 6
assert val == 'foo'
assert kw == {'fee': True}


def test_curry_single_arity():
assert run_in_loop(curry(lambda x: x)(True))


def test_curry_zero_arity():
assert run_in_loop(curry(lambda: True))


def test_curry_custom_arity():
currier = curry(4)
num, val, kw = run_in_loop(currier(task)(2)(4)(baz='foo')(tee=True))
assert num == 6
assert val == 'foo'
assert kw == {'tee': True}


def test_curry_ignore_kwargs():
currier = curry(ignore_kwargs=True)
num, val, kw = run_in_loop(currier(task)(2)(4))
assert num == 6
assert val is None
assert kw == {}

currier = curry(ignore_kwargs=True)
num, val, kw = run_in_loop(currier(task)(2)(4, baz='foo', tee=True))
assert num == 6
assert val is 'foo'
assert kw == {'tee': True}


def test_curry_extra_arguments():
currier = curry(4)
num, val, kw = run_in_loop(currier(task)(2)(4)(baz='foo')(tee=True))
assert num == 6
assert val == 'foo'
assert kw == {'tee': True}

currier = curry(4)
num, val, kw = run_in_loop(currier(task)(2)(4)(baz='foo')(tee=True))
assert num == 6
assert val == 'foo'
assert kw == {'tee': True}


def test_curry_evaluator_function():
def evaluator(acc, fn):
return len(acc[0]) < 3

def task(x, y):
return x * y

currier = curry(evaluator=evaluator)
assert run_in_loop(currier(task)(4, 4)) == 16


def test_curry_decorator():
@curry
def task(x, y, z):
return x + y + z

assert run_in_loop(task(2)(4)(8)) == 14

@curry(4)
def task(x, y, *args):
return x + y + args[0] + args[1]

assert run_in_loop(task(2)(4)(8)(10)) == 24

@curry(4)
@asyncio.coroutine
def task(x, y, *args):
return x + y + args[0] + args[1]

assert run_in_loop(task(2)(4)(8)(10)) == 24


def test_curry_coroutine():
num, val, kw = run_in_loop(curry(coro)(2)(4)(baz='foo', tee=True))
assert num == 6
assert val == 'foo'
assert kw == {'tee': True}

0 comments on commit ff87bb6

Please sign in to comment.