diff --git a/docs/index.rst b/docs/index.rst index 16f947ae1..95f303ee3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,6 +40,7 @@ Contents pages/functions.rst pages/curry.rst pages/types.rst + pages/transducers.rst .. toctree:: :maxdepth: 2 diff --git a/docs/pages/transducers.rst b/docs/pages/transducers.rst new file mode 100644 index 000000000..53f29959d --- /dev/null +++ b/docs/pages/transducers.rst @@ -0,0 +1,14 @@ +.. transducers: + +Transducers +=========== + +API Reference +------------- + +.. automodule:: returns.transducers.transducers + :members: + +.. autofunction:: returns.transducers.tmap + +.. autofunction:: returns.transducers.tfilter diff --git a/returns/transducers/__init__.py b/returns/transducers/__init__.py new file mode 100644 index 000000000..a6528e81c --- /dev/null +++ b/returns/transducers/__init__.py @@ -0,0 +1,6 @@ +from returns.transducers.tfilter import tfilter as tfilter +from returns.transducers.tmap import tmap as tmap +from returns.transducers.transducers import Missing as Missing +from returns.transducers.transducers import Reduced as Reduced +from returns.transducers.transducers import transduce as transduce +from returns.transducers.transducers import treduce as treduce diff --git a/returns/transducers/tfilter.py b/returns/transducers/tfilter.py new file mode 100644 index 000000000..edeaa53c3 --- /dev/null +++ b/returns/transducers/tfilter.py @@ -0,0 +1,41 @@ +from typing import Callable, TypeVar + +_ValueType = TypeVar('_ValueType') +_AccValueType = TypeVar('_AccValueType') + + +def tfilter( + predicate: Callable[[_ValueType], bool], +) -> Callable[ + [Callable[[_AccValueType, _ValueType], _AccValueType]], + Callable[[_AccValueType, _ValueType], _AccValueType], +]: + """ + :py:func:`filter ` implementation on a transducer form. + + .. code:: python + + >>> from typing import List + >>> from returns.transducers import tfilter, treduce + + >>> def is_even(number: int) -> bool: + ... return number % 2 == 0 + + >>> def append(collection: List[int], item: int) -> List[int]: + ... collection.append(item) + ... return collection + + >>> my_list = [0, 1, 2, 3, 4, 5, 6] + >>> xform = tfilter(is_even)(append) + >>> assert treduce(xform, my_list, []) == [0, 2, 4, 6] + + """ + def reducer( + step: Callable[[_AccValueType, _ValueType], _AccValueType], + ) -> Callable[[_AccValueType, _ValueType], _AccValueType]: + def filter_(acc: _AccValueType, value: _ValueType) -> _AccValueType: + if predicate(value): + return step(acc, value) + return acc + return filter_ + return reducer diff --git a/returns/transducers/tmap.py b/returns/transducers/tmap.py new file mode 100644 index 000000000..996368ddc --- /dev/null +++ b/returns/transducers/tmap.py @@ -0,0 +1,41 @@ +from typing import Callable, TypeVar + +_ValueType = TypeVar('_ValueType') +_NewValueType = TypeVar('_NewValueType') + +_AccValueType = TypeVar('_AccValueType') + + +def tmap( + function: Callable[[_ValueType], _NewValueType], +) -> Callable[ + [Callable[[_AccValueType, _NewValueType], _AccValueType]], + Callable[[_AccValueType, _ValueType], _AccValueType], +]: + """ + A map implementation on a transducer form. + + .. code:: python + + >>> from typing import List + >>> from returns.transducers import tmap, treduce + + >>> def add_one(number: int) -> int: + ... return number + 1 + + >>> def append(collection: List[int], item: int) -> List[int]: + ... collection.append(item) + ... return collection + + >>> my_list = [0, 1] + >>> xformaa = tmap(add_one)(append) + >>> assert treduce(xformaa, my_list, []) == [1, 2] + + """ + def reducer( + step: Callable[[_AccValueType, _NewValueType], _AccValueType], + ) -> Callable[[_AccValueType, _ValueType], _AccValueType]: + def map_(acc: _AccValueType, value: _ValueType) -> _AccValueType: + return step(acc, function(value)) + return map_ + return reducer diff --git a/returns/transducers/transducers.py b/returns/transducers/transducers.py new file mode 100644 index 000000000..ea03b38d0 --- /dev/null +++ b/returns/transducers/transducers.py @@ -0,0 +1,158 @@ +from typing import ( + Any, + Callable, + Generic, + Iterable, + Optional, + TypeVar, + final, + overload, +) + +from returns.primitives.types import Immutable + +_ValueType = TypeVar('_ValueType') +_NewValueType = TypeVar('_NewValueType') + +_AccValueType = TypeVar('_AccValueType') + + +@final +class Reduced(Immutable, Generic[_ValueType]): + """ + Sentinel for early termination inside transducer. + + .. code:: python + + >>> from returns.transducers import tmap, transduce, Reduced + + >>> def add_one(number: int) -> int: + ... return number + 1 + + >>> def add(acc: int, number: int) -> int: + ... if acc == 3: + ... return Reduced(acc) + ... return acc + number + + >>> my_list = [0, 1, 2] + >>> assert transduce(tmap(add_one), add, 0, my_list) == 3 + + """ + + __slots__ = ('_inner_value',) + + _inner_value: _ValueType + + def __init__(self, inner_value: _ValueType) -> None: + """Encapsulates the value from early reduce termination.""" + object.__setattr__(self, '_inner_value', inner_value) # noqa: WPS609 + + @property + def value(self) -> _ValueType: # noqa: WPS110 + """Returns the value from early reduce termination.""" + return self._inner_value + + +@final +class _Missing(Immutable): + """Represents a missing value for reducers.""" + + __slots__ = ('_instance',) + + _instance: Optional['_Missing'] = None + + def __new__(cls, *args: Any, **kwargs: Any) -> '_Missing': + if cls._instance is None: + cls._instance = object.__new__(cls) # noqa: WPS609 + return cls._instance + + +#: A singleton representing any missing value +Missing = _Missing() + + +def transduce( + xform: Callable[ + [Callable[[_AccValueType, _ValueType], _AccValueType]], + Callable[[_AccValueType, _ValueType], _AccValueType], + ], + reducing_function: Callable[[_AccValueType, _ValueType], _AccValueType], + initial: _AccValueType, + iterable: Iterable[_ValueType], +) -> _AccValueType: + """ + Process information with transducers. + + .. code:: python + + >>> from returns.transducers import tmap, transduce + + >>> def add_one(number: int) -> int: + ... return number + 1 + + >>> def add(acc: int, number: int) -> int: + ... return acc + number + + >>> my_list = [0, 1, 2] + >>> assert transduce(tmap(add_one), add, 0, my_list) == 6 + """ + reducer = xform(reducing_function) + return treduce(reducer, iterable, initial) + + +@overload +def treduce( + function: Callable[[_ValueType, _ValueType], _ValueType], + iterable: Iterable[_ValueType], + initial: _Missing = Missing, +) -> _ValueType: + """Reduce without an initial value.""" + + +@overload +def treduce( + function: Callable[[_AccValueType, _ValueType], _AccValueType], + iterable: Iterable[_ValueType], + initial: _AccValueType, +) -> _AccValueType: + """Reduce with an initial value.""" + + +def treduce(function, iterable, initial=Missing): + """ + A rewritten version of :func:`reduce `. + + This version considers some features borrowed from Clojure: + + - Early termination + - Function initializer [TODO] + + You can use it as a normal reduce if you want: + + .. code:: python + + >>> from returns.transducers import treduce + + >>> def add(acc: int, value: int) -> int: + ... return acc + value + + >>> assert treduce(add, [1, 2, 3]) == 6 + + """ + it = iter(iterable) + + if initial is Missing: + try: + acc_value = next(it) + except StopIteration: + raise TypeError( + 'reduce() of empty iterable with no initial value', + ) from None + else: + acc_value = initial + + for value in it: # noqa: WPS110 + acc_value = function(acc_value, value) + if isinstance(acc_value, Reduced): + return acc_value.value + return acc_value diff --git a/setup.cfg b/setup.cfg index 4fc23e6b5..8ee88b023 100644 --- a/setup.cfg +++ b/setup.cfg @@ -69,6 +69,7 @@ per-file-ignores = returns/methods/__init__.py: F401, WPS201 returns/pipeline.py: F401 returns/context/__init__.py: F401, WPS201 + returns/transducers/__init__.py: F401 # Disable some quality checks for the most heavy parts: returns/io.py: WPS402 returns/iterables.py: WPS234 @@ -77,6 +78,8 @@ per-file-ignores = returns/primitives/asserts.py: S101 # Some rules cannot be applied to context: returns/context/*.py: WPS201, WPS204, WPS226, WPS326, WPS430 + # Some rules cannot be applied to transducers: + returns/transducers/*.py: WPS110, WPS430 # We allow `futures` to do attribute access: returns/future.py: WPS437 returns/_internal/futures/*.py: WPS204, WPS433, WPS437 diff --git a/tests/test_transducers/test_transducers/test_missing_singleton.py b/tests/test_transducers/test_transducers/test_missing_singleton.py new file mode 100644 index 000000000..cc4b74a12 --- /dev/null +++ b/tests/test_transducers/test_transducers/test_missing_singleton.py @@ -0,0 +1,6 @@ +from returns.transducers.transducers import _Missing + + +def test_missing_singleton(): + """Ensures `_Missing` is a singleton.""" + assert _Missing() is _Missing() diff --git a/tests/test_transducers/test_transducers/test_treduce.py b/tests/test_transducers/test_transducers/test_treduce.py new file mode 100644 index 000000000..82e9e735b --- /dev/null +++ b/tests/test_transducers/test_transducers/test_treduce.py @@ -0,0 +1,9 @@ +import pytest + +from returns.transducers import treduce + + +def test_reduce(): + """Should fail when iterable is empty and non initial value is given.""" + with pytest.raises(TypeError): + treduce(lambda acc, value: acc + value, []) # noqa: WPS110 diff --git a/typesafety/test_transducers/test_tfilter.yml b/typesafety/test_transducers/test_tfilter.yml new file mode 100644 index 000000000..41b712d8b --- /dev/null +++ b/typesafety/test_transducers/test_tfilter.yml @@ -0,0 +1,73 @@ +- case: tfilter + disable_cache: false + main: | + from returns.transducers import tfilter + + def is_even(number: int) -> bool: + ... + + reveal_type(tfilter(is_even)) # N: Revealed type is 'def [_AccType] (def (_AccType`-2, builtins.int*) -> _AccType`-2) -> def (_AccType`-2, builtins.int*) -> _AccType`-2' + + +- case: tfilter_reducer + disable_cache: false + main: | + from typing import List + from returns.transducers import tfilter + + def is_even(number: int) -> bool: + ... + + def append(collection: List[int], item: int) -> List[int]: + ... + + reveal_type(tfilter(is_even)(append)) # N: Revealed type is 'def (builtins.list*[builtins.int], builtins.int) -> builtins.list*[builtins.int]' + + +- case: tfilter_reducer_filter_ + disable_cache: false + main: | + from typing import List + from returns.transducers import tfilter, reduce + + def is_even(number: int) -> bool: + ... + + def append(collection: List[int], item: int) -> List[int]: + ... + + my_list: List[int] + reveal_type(tfilter(is_even)(append)(my_list, 2)) # N: Revealed type is 'builtins.list*[builtins.int]' + + +- case: tfilter_composition_one + disable_cache: false + main: | + from typing import List + from returns.transducers import tfilter, reduce + + def is_even(number: int) -> bool: + ... + + def append(collection: List[int], item: int) -> List[int]: + ... + + composed = tfilter(is_even)(tfilter(is_even)(append)) + reveal_type(composed) # N: Revealed type is 'def (builtins.list*[builtins.int], builtins.int) -> builtins.list*[builtins.int]' + + +- case: tfilter_composition_two + disable_cache: false + main: | + from typing import List + from returns.transducers import tfilter, reduce + + def is_even(number: int) -> bool: + ... + + def append(collection: List[int], item: int) -> List[int]: + ... + + composed = tfilter(is_even)(tfilter(is_even)(append)) + my_list: List[int] + reveal_type(composed(my_list, 42)) # N: Revealed type is 'builtins.list*[builtins.int]' diff --git a/typesafety/test_transducers/test_tmap.yml b/typesafety/test_transducers/test_tmap.yml new file mode 100644 index 000000000..3e33b0958 --- /dev/null +++ b/typesafety/test_transducers/test_tmap.yml @@ -0,0 +1,78 @@ +- case: tmap + disable_cache: false + main: | + from returns.transducers import tmap + + def to_str(number: int) -> str: + ... + + reveal_type(tmap(to_str)) # N: Revealed type is 'def [_AccType] (def (_AccType`-3, builtins.str*) -> _AccType`-3) -> def (_AccType`-3, builtins.int*) -> _AccType`-3' + + +- case: tmap_reducer + disable_cache: true + main: | + from typing import List + from returns.transducers import tmap + + def to_str(number: int) -> str: + ... + + def append(collection: List[str], item: str) -> List[str]: + ... + + reveal_type(tmap(to_str)(append)) # N: Revealed type is 'def (builtins.list*[builtins.str], builtins.int) -> builtins.list*[builtins.str]' + + +- case: tmap_reducer_map_ + disable_cache: false + main: | + from typing import List + from returns.transducers import tmap + + def to_str(number: int) -> str: + ... + + def append(collection: List[str], item: str) -> List[str]: + ... + + my_list: List[str] + reveal_type(tmap(to_str)(append)(my_list, 2)) # N: Revealed type is 'builtins.list*[builtins.str]' + + +- case: tmap_composition_one + disable_cache: true + main: | + from typing import List + from returns.transducers import tmap + + def to_str(number: int) -> str: + ... + + def to_int(string: str) -> int: + ... + + def append(collection: List[str], item: str) -> List[str]: + ... + + composed = tmap(to_int)(tmap(to_str)(append)) + reveal_type(composed) # N: Revealed type is 'def (builtins.list*[builtins.str], builtins.str) -> builtins.list*[builtins.str]' + + +- case: tmap_composition_two + disable_cache: true + main: | + from typing import List + from returns.transducers import tmap + + def to_str(number: int) -> str: + ... + + def to_int(string: str) -> int: + ... + + def append(collection: List[str], item: str) -> List[str]: + ... + + composed = tmap(to_int)(tmap(to_str)(append)) + reveal_type(composed([], '1')) # N: Revealed type is 'builtins.list*[builtins.str]'