diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 83a2063..1ef564d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit - rev: 0.5.26 + rev: 0.11.2 hooks: - id: uv-lock - repo: local diff --git a/README.md b/README.md index bda99cc..a92e154 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,7 @@ Alternatively the map method can be used to return a new type instance with the ├── src │ └── danom │ ├── __init__.py +│ ├── _either.py │ ├── _new_type.py │ ├── _result.py │ ├── _safe.py @@ -276,6 +277,7 @@ Alternatively the map method can be used to return a new type instance with the │ ├── conftest.py │ ├── test_api.py │ ├── test_benchmarks.py +│ ├── test_either.py │ ├── test_monad_laws.py │ ├── test_new_type.py │ ├── test_result.py diff --git a/src/danom/__init__.py b/src/danom/__init__.py index 0dbc765..0b197f7 100644 --- a/src/danom/__init__.py +++ b/src/danom/__init__.py @@ -1,3 +1,4 @@ +from danom._either import Either, Left, Right from danom._new_type import new_type from danom._result import Err, Ok, Result from danom._safe import safe, safe_method @@ -5,9 +6,12 @@ from danom._utils import all_of, any_of, compose, identity, invert, none_of __all__ = [ + "Either", "Err", + "Left", "Ok", "Result", + "Right", "Stream", "all_of", "any_of", diff --git a/src/danom/_either.py b/src/danom/_either.py new file mode 100644 index 0000000..1aa64f3 --- /dev/null +++ b/src/danom/_either.py @@ -0,0 +1,213 @@ +from __future__ import annotations + +from abc import ABC, abstractmethod +from collections.abc import Callable +from typing import Any, Concatenate, Literal, Never, ParamSpec, Self, TypeVar + +import attrs + +T_co = TypeVar("T_co", covariant=True) +U_co = TypeVar("U_co", covariant=True) +E_co = TypeVar("E_co", bound=object, covariant=True) +F_co = TypeVar("F_co", bound=object, covariant=True) +P = ParamSpec("P") + +Mappable = Callable[Concatenate[T_co, P], U_co] +Bindable = Callable[Concatenate[T_co, P], "Either[U_co, E_co]"] + + +@attrs.define(frozen=True) +class Either[T_co, E_co: object](ABC): + """`Either` monad. Consists of `Right` and `Left` for successful and failed operations respectively. + Each monad is a frozen instance to prevent further mutation. + """ + + @classmethod + def unit(cls, inner: T_co) -> Right[T_co]: + """Unit method. Given an item of type `T_co` return `Right(T_co)` + + .. doctest:: + + >>> from danom import Left, Right, Either + + >>> Either.unit(0) == Right(inner=0) + True + + >>> Right.unit(0) == Right(inner=0) + True + + >>> Left.unit(0) == Right(inner=0) + True + """ + return Right(inner) + + @abstractmethod + def is_ok(self) -> bool: + """Returns `True` if the result type is `Right`. + Returns `False` if the result type is `Left`. + + .. doctest:: + + >>> from danom import Left, Right + + >>> Right().is_ok() == True + True + + >>> Left().is_ok() == False + True + """ + ... + + @abstractmethod + def map(self, func: Mappable, *args: P.args, **kwargs: P.kwargs) -> Either[U_co, E_co]: + """Pipe a pure function and wrap the return value with `Right`. + Given an `Left` will return self. + + .. code-block:: python + + from danom import Left, Right + + Right(1).map(add_one) == Right(2) + Left(inner=TypeError()).map(add_one) == Left(inner=TypeError()) + """ + ... + + @abstractmethod + def map_err(self, func: Mappable, *args: P.args, **kwargs: P.kwargs) -> Either[U_co, E_co]: + """Pipe a pure function and wrap the return value with `Left`. + Given an `Right` will return self. + + .. code-block:: python + + from danom import Left, Right + + Left(inner=TypeError()).map_err(type_err_to_value_err) == Left(inner=ValueError()) + Right(1).map(type_err_to_value_err) == Right(1) + """ + ... + + @abstractmethod + def and_then(self, func: Bindable, *args: P.args, **kwargs: P.kwargs) -> Either[U_co, E_co]: + """Pipe another function that returns a monad. For `Left` will return original error. + + .. code-block:: python + + from danom import Left, Right + + Right(1).and_then(add_one) == Right(2) + Right(1).and_then(raise_err) == Left(inner=TypeError()) + Left(inner=TypeError()).and_then(add_one) == Left(inner=TypeError()) + Left(inner=TypeError()).and_then(raise_value_err) == Left(inner=TypeError()) + """ + ... + + @abstractmethod + def or_else(self, func: Bindable, *args: P.args, **kwargs: P.kwargs) -> Either[U_co, E_co]: + """Pipe a function that returns a monad to recover from an `Left`. For `Right` will return original `Either`. + + .. code-block:: python + + from danom import Left, Right + + Right(1).or_else(replace_err_with_zero) == Right(1) + Left(inner=TypeError()).or_else(replace_err_with_zero) == Right(0) + """ + ... + + @abstractmethod + def unwrap(self) -> T_co: + """Unwrap the `Right` monad and get the inner value. + Unwrap the `Left` monad will raise the inner error. + + .. doctest:: + + >>> from danom import Left, Right + + >>> Right().unwrap() == None + True + + >>> Right(1).unwrap() == 1 + True + + >>> Right("ok").unwrap() == 'ok' + True + + >>> Left(-1).unwrap() == -1 + True + + """ + ... + + @staticmethod + def either_is_ok(result: Either[T_co, E_co]) -> bool: + """Check whether the monad is ok. Allows for ``filter`` or ``partition`` in a ``Stream`` without needing a lambda or custom function. + + .. code-block:: python + + from danom import Stream, Either + + Stream.from_iterable([Right(), Right(), Left()]).filter(Either.either_is_ok).collect() == (Right(), Right()) + + """ + return result.is_ok() + + @staticmethod + def either_unwrap(result: Either[T_co, E_co]) -> T_co: + """Unwrap the `Right` monad and get the inner value. + Unwrap the `Left` monad will raise the inner error. + + .. code-block:: python + + from danom import Stream, Either + + oks, errs = Stream.from_iterable([Right(1), Right(2), Left()]).partition(Either.either_is_ok) + oks.map(Either.either_unwrap).collect == (1, 2) + + """ + return result.unwrap() + + +@attrs.define(frozen=True, hash=True) +class Right(Either[T_co, Never]): + inner: Any = attrs.field(default=None) + + def is_ok(self) -> Literal[True]: + return True + + def map(self, func: Mappable, *args: P.args, **kwargs: P.kwargs) -> Right[U_co]: + return Right(func(self.inner, *args, **kwargs)) + + def map_err(self, func: Mappable, *args: P.args, **kwargs: P.kwargs) -> Self: # noqa: ARG002 + return self + + def and_then(self, func: Bindable, *args: P.args, **kwargs: P.kwargs) -> Either[U_co, E_co]: + return func(self.inner, *args, **kwargs) + + def or_else(self, func: Bindable, *args: P.args, **kwargs: P.kwargs) -> Self: # noqa: ARG002 + return self + + def unwrap(self) -> T_co: + return self.inner + + +@attrs.define(frozen=True, hash=True) +class Left(Either[Never, E_co]): + inner: Any = attrs.field(default=None) + + def is_ok(self) -> Literal[False]: + return False + + def map(self, func: Mappable, *args: P.args, **kwargs: P.kwargs) -> Self: # noqa: ARG002 + return self + + def map_err(self, func: Mappable, *args: P.args, **kwargs: P.kwargs) -> Left[F_co]: + return Left(func(self.inner, *args, **kwargs)) + + def and_then(self, func: Bindable, *args: P.args, **kwargs: P.kwargs) -> Self: # noqa: ARG002 + return self + + def or_else(self, func: Bindable, *args: P.args, **kwargs: P.kwargs) -> Either[U_co, E_co]: + return func(self.inner, *args, **kwargs) + + def unwrap(self) -> T_co: + return self.inner diff --git a/tests/test_either.py b/tests/test_either.py new file mode 100644 index 0000000..dce22d6 --- /dev/null +++ b/tests/test_either.py @@ -0,0 +1,84 @@ +import pytest + +from danom import Either, Left, Right +from tests.conftest import add_one + + +@pytest.mark.parametrize( + ("monad", "inner"), + [ + pytest.param(Right, 0), + pytest.param(Right, "ok"), + pytest.param(Left, 0), + pytest.param(Either, 0), + ], +) +def test_unit(monad, inner): + assert monad.unit(inner) == Right(inner) + + +@pytest.mark.parametrize( + ("left", "right", "expected_result"), + [ + pytest.param(Right(), Left(), False), + pytest.param(Right(), Right(), True), + pytest.param(Left(), Left(), True), + ], +) +def test_result_equality(left, right, expected_result): + assert (left == right) == expected_result + + +@pytest.mark.parametrize( + ("monad", "expected_result", "expected_context"), + [pytest.param(Either, None, pytest.raises(TypeError))], +) +def test_result_unwrap(monad, expected_result, expected_context): + with expected_context: + assert monad().unwrap() == expected_result + + +@pytest.mark.parametrize( + "monad", [pytest.param(Right, id="for Right monad"), pytest.param(Left, id="for Left monad")] +) +@pytest.mark.parametrize("inner", [pytest.param(0), pytest.param("something"), pytest.param([])]) +def test_unwrap(monad, inner): + assert monad(inner).unwrap() == inner + + +@pytest.mark.parametrize( + ("monad", "expected_result"), [pytest.param(Right(), True), pytest.param(Left(), False)] +) +def test_is_ok(monad, expected_result): + assert monad.is_ok() == expected_result + + +@pytest.mark.parametrize( + ("monad", "func", "expected_result"), + [pytest.param(Right(0), add_one, Right(1)), pytest.param(Left(), add_one, Left())], +) +def test_map(monad, func, expected_result): + assert monad.map(func) == expected_result + + +@pytest.mark.parametrize( + ("monad", "func", "expected_result"), + [pytest.param(Right(0), add_one, Right(0)), pytest.param(Left(0), add_one, Left(1))], +) +def test_map_err(monad, func, expected_result): + assert monad.map_err(func) == expected_result + + +@pytest.mark.parametrize( + ("monad", "expected_result"), [pytest.param(Right(), True), pytest.param(Left(), False)] +) +def test_staticmethod_result_is_ok(monad, expected_result): + assert Either.either_is_ok(monad) == expected_result + + +@pytest.mark.parametrize( + "monad", [pytest.param(Right, id="for Right monad"), pytest.param(Left, id="for Left monad")] +) +@pytest.mark.parametrize("inner", [pytest.param(0), pytest.param("something"), pytest.param([])]) +def test_staticmethod_result_unwrap(monad, inner): + assert Either.either_unwrap(monad(inner)) == inner diff --git a/tests/test_monad_laws.py b/tests/test_monad_laws.py index 180039b..2691097 100644 --- a/tests/test_monad_laws.py +++ b/tests/test_monad_laws.py @@ -1,38 +1,39 @@ +from __future__ import annotations + from hypothesis import given from hypothesis import strategies as st -from danom import Err, Result -from tests.conftest import safe_add_one, safe_double +from danom import Either, Err, Left, Result -inners = st.one_of( - st.integers().map(Result.unit), - st.text().map(Result.unit), - st.floats(allow_nan=False, allow_infinity=False).map(Result.unit), -) -results = st.one_of( - st.integers().map(Result.unit), - st.text().map(Result.unit), - st.floats(allow_nan=False, allow_infinity=False).map(Result.unit), - st.just(Err(1)), -) +def monad_tests(parent: type[Result | Either], err_monad: type[Err | Left]): + inners = st.one_of(st.integers(), st.text(), st.floats(allow_nan=False, allow_infinity=False)) + results = st.one_of(inners.map(parent.unit), st.just(err_monad(1))) + safe_fns = st.sampled_from([lambda x: parent.unit(x * 2), lambda x: err_monad(x)]) -safe_fns = st.sampled_from([safe_double, safe_add_one]) + @given(inner=inners, f=safe_fns) + def test_monadic_left_identity(inner, f): + assert parent.unit(inner).and_then(f) == f(inner) + @given(results) + def test_monadic_right_identity(monad): + assert monad.and_then(parent.unit) == monad -@given(inner=inners, f=safe_fns) -def test_monadic_left_identity(inner, f): - assert Result.unit(inner).and_then(f) == f(inner) + @given(monad=results, f=safe_fns, g=safe_fns, h=safe_fns) + def test_monadic_associativity(monad, f, g, h): + assert monad.and_then(f).and_then(g).or_else(h) == monad.and_then( + lambda x: f(x).and_then(g) + ).or_else(h) + return test_monadic_left_identity, test_monadic_right_identity, test_monadic_associativity -@given(results) -def test_monadic_right_identity(monad): - assert monad.and_then(Result.unit) == monad + +test_result_left_identity, test_result_right_identity, test_result_associativity = monad_tests( + Result, Err +) -@given(monad=results, f=safe_fns, g=safe_fns, h=safe_fns) -def test_monadic_associativity(monad, f, g, h): - assert monad.and_then(f).and_then(g).or_else(h) == monad.and_then( - lambda x: f(x).and_then(g) - ).or_else(h) +test_either_left_identity, test_either_right_identity, test_either_associativity = monad_tests( + Either, Left +)