fabiommendes/sidekick

View on GitHub
sidekick-functions/sidekick/functions/pred.py

Summary

Maintainability
A
2 hrs
Test Coverage
from itertools import zip_longest

from ._toolz import isdistinct, isiterable
from .functions import always
from .functions import fn, quick_fn, to_callable
from .typing import Callable, overload, Any, TypeCheck, Seq, NOT_GIVEN, TYPE_CHECKING

if TYPE_CHECKING:
    import sidekick.api as sk  # noqa: F401
    from sidekick.api import _  # noqa: F401

__all__ = [
    # Compositions
    "cond",
    "any_pred",
    "all_pred",
    # Value testing
    "is_a",
    "is_equal",
    "is_identical",
    "is_false",
    "is_true",
    "is_none",
    "is_truthy",
    "is_falsy",
    # Numeric tests
    "is_even",
    "is_odd",
    "is_negative",
    "is_positive",
    "is_strictly_negative",
    "is_strictly_positive",
    "is_zero",
    "is_nonzero",
    "is_divisible_by",
    # String tests
    "has_pattern",
    # Sequences
    "is_distinct",
    "is_iterable",
    "is_seq_equal",
]


@fn.curry(3)
def cond(test, then, else_):
    """
    Conditional evaluation.

    Return a function that tests the argument with the cond function, and then
    executes either the ``then`` or ``else_`` branches.

    Examples:
        >>> from sidekick.api import placeholder as _
        >>> collatz = sk.cond(sk.is_even, _ // 2, (3 * _) + 1)
        >>> [collatz(1), collatz(2), collatz(3), collatz(4)]
        [4, 1, 10, 2]
    """
    test = to_callable(test)
    then = to_callable(then)
    else_ = to_callable(else_)
    return (
        lambda *args, **kwargs: then(*args, **kwargs)
        if test(*args, **kwargs)
        else else_(*args, **kwargs)
    )


#
# Predicate factories
#
def any_pred(*predicates):
    """
    Return a new predicate function that performs a logic OR to all arguments.

    This behaves in a short-circuit manner and returns the first truthy result,
    if found, or the last falsy result, otherwise.
    """

    if len(predicates) == 0:
        return always(False)
    elif len(predicates) == 1:
        return predicates[0]

    @quick_fn
    def predicate(*args, **kwargs):
        res = False
        for f in predicate:
            res = f(*args, **kwargs)
            if res:
                return res
        return res

    return predicate


def all_pred(*predicates):
    """
    Return a new predicate that performs an logic AND to all predicate
    functions.

    This behaves in a short-circuit manner and returns the first falsy result,
    if found, or the last truthy result, otherwise.
    """

    if len(predicates) == 0:
        return always(True)
    elif len(predicates) == 1:
        return predicates[0]

    @quick_fn
    def predicate(*args, **kwargs):
        res = True
        for f in predicate:
            res = f(*args, **kwargs)
            if not res:
                return res
        return res

    return predicate


#
# Predicate functions
#
@fn.curry(2)
def is_identical(x, y):
    """
    Check if ``x is y``.
    """
    return x is y


@fn.curry(2)
def is_equal(x, y):
    """
    Check if ``x == y``.
    """
    return x == y


def _is_identical(
    value,
    name=None,
    render=None,
    doc=None,
    func=None,
    by="by identity",
    ok=None,
    bad=None,
):
    render = render or repr(value)
    name = name or f"is_{render.lower()}"
    ok = ok if ok is not None else f"{value!r}"
    bad = bad if bad is not None else f'"not_{render.lower()}"'
    doc = (
        doc
        or f"""
    Check if argument is {render.replace('_', ' ')}{by}.

    Examples:
        >>> sk.{name}({bad})
        False
        >>> sk.{name}({ok})
        True
    """
    )

    if func is None:

        @fn
        def id_check(x):
            return x is value

    else:
        id_check = fn(func)

    id_check.__name__ = id_check.__qualname__ = name
    id_check.__doc__ = doc
    return id_check


is_none = _is_identical(None)
is_true = _is_identical(True)
is_false = _is_identical(False)


@fn
def is_truthy(x):
    """
    Check if argument is truthy.

    This is the same as calling ``bool(x)``
    """
    return bool(x)


@fn
def is_falsy(x):
    """
    Check if argument is falsy.

    This is the same as calling ``not bool(x)``
    """
    return not bool(x)


# noinspection PyOverloads
@overload
def is_a(cls: TypeCheck) -> Callable[[Any], bool]:
    ...


@overload
def is_a(cls: TypeCheck, x: Any) -> bool:  # noqa: F811
    ...


@fn.curry(2)
def is_a(cls, x):  # noqa: F811
    """
    Check if x is an instance of cls.

    Equivalent to isinstance, but auto-curried and with the order or arguments
    flipped.

    Args:
        cls:
            Type or tuple of types to test for.
        x:
            Instance.

    Examples:
        >>> is_int = sk.is_a(int)
        >>> is_int(42), is_int(42.0)
        (True, False)
    """
    return isinstance(x, cls)


# Numeric
_is_numeric = lambda name, func, ok, bad: _is_identical(
    None, render=name, by="", ok=ok, bad=bad, func=func
)
is_odd = _is_numeric("odd", lambda x: x % 2 == 1, 1, 42)
is_even = _is_numeric("even", lambda x: x % 2 == 0, 42, 1)
is_positive = _is_numeric("positive", lambda x: x >= 0, 42, -10)
is_negative = _is_numeric("negative", lambda x: x <= 0, -10, 42)
is_strictly_positive = _is_numeric("strictly_positive", lambda x: x > 0, 42, 0)
is_strictly_negative = _is_numeric("strictly_negative", lambda x: x < 0, -10, 0)
is_nonzero = _is_numeric("nonzero", lambda x: x != 0, 42, 0)
is_zero = _is_numeric("zero", lambda x: x == 0, 0, 42)


@fn.curry(2)
def is_divisible_by(n, x):
    """
    Check if x is divisible by n.

    Examples:
        >>> sk.is_divisible_by(2, 42)  # 42 is divisible by 2
        True
        >>> even = sk.is_divisible_by(2)
        >>> even(4), even(3)
        (True, False)
    """
    return x % n == 0


# Sequences
@fn
def is_distinct(seq: Seq) -> bool:
    """
    Test if all elements in sequence are distinct.
    """
    return isdistinct(seq)


@fn
def is_iterable(obj) -> bool:
    """
    Test if argument is iterable.
    """
    return isiterable(obj)


@fn.curry(2)
def is_seq_equal(seq1, seq2):
    """
    Return True if the two sequences are equal.
    """
    return all(x == y for x, y in zip_longest(seq1, seq2, fillvalue=object()))


# Strings
@overload
def has_pattern(pattern: str) -> Callable[[str], bool]:
    ...


@overload
def has_pattern(pattern: str, st: str) -> bool:  # noqa: F811
    ...


def has_pattern(pattern, st=NOT_GIVEN):  # noqa: F811
    r"""
    Check if string contains pattern.

    This function performs a pattern scan. If you want to match the beginning of
    the string, make it start with a "^". Add a "$" if it needs to match the
    end.

    Examples:
        >>> sk.has_pattern(r"\d{2}", "year, 1942")
        True
        >>> sk.has_pattern(r"^\d{2}$", "year, 1942")
        False

        This function is also very useful to filter or process string data.

        >>> is_date = sk.has_pattern("^\d{4}-\d{2}-\d{2}$")
        >>> is_date("1917-03-08")
        True
        >>> is_date("08/03/1917")
        False
    """

    import re

    if st is NOT_GIVEN:
        regex = re.compile(pattern)

        def check_pattern(data: str) -> bool:
            return regex.search(data) is not None

        return check_pattern

    else:
        return re.search(pattern, st) is not None


#
# Auxiliary functions
#
def _other_pred(x):
    if isinstance(x, fn):
        # noinspection PyProtectedMember
        return x.__sk_callable__
    raise TypeError("can only compose with other predicate functions")