From d1f7228d346d8827dad034a776bc1ed03b7e7453 Mon Sep 17 00:00:00 2001 From: ed cuss Date: Tue, 5 May 2026 20:24:29 +0100 Subject: [PATCH] feat: flatten --- src/danom/_result.py | 25 +++++++++++++++++++++++-- tests/test_result.py | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/danom/_result.py b/src/danom/_result.py index c9af8fc..b4d99c6 100644 --- a/src/danom/_result.py +++ b/src/danom/_result.py @@ -174,6 +174,27 @@ def result_unwrap(result: Result[T_co, E_co]) -> T_co: """ return result.unwrap() + def flatten(self) -> Result[T_co, E_co]: + """Flatten the monad. Will return the first ``Err`` or the lowest ``Ok`` instance. + + .. doctest:: + + >>> from danom import Err, Ok, Stream, Result + + >>> Ok(Ok(Ok(1))).flatten() == Ok(1) + True + + >>> Ok(Ok(Err())).flatten() == Err() + True + + """ + current = self + + while isinstance(current, Ok) and isinstance(current.inner, Result): + current = current.inner + + return current + @attrs.define(frozen=True, hash=True) class Ok(Result[T_co, Never]): @@ -191,7 +212,7 @@ def map_err[**P](self, func: Mappable, *args: P.args, **kwargs: P.kwargs) -> Sel def and_then[**P]( self, func: Bindable, *args: P.args, **kwargs: P.kwargs ) -> Result[T_co, E_co]: - return func(self.inner, *args, **kwargs) + return Ok(func(self.inner, *args, **kwargs)).flatten() def or_else[**P](self, func: Recoverable, *args: P.args, **kwargs: P.kwargs) -> Self: # noqa: ARG002 return self @@ -248,7 +269,7 @@ def and_then[**P](self, func: Bindable, *args: P.args, **kwargs: P.kwargs) -> Se def or_else[**P]( self, func: Recoverable, *args: P.args, **kwargs: P.kwargs ) -> Result[T_co, E_co]: # ty: ignore[invalid-method-override] - return func(self.error, *args, **kwargs) + return Ok(func(self.error, *args, **kwargs)).flatten() def unwrap(self) -> T_co: if isinstance(self.error, Exception): diff --git a/tests/test_result.py b/tests/test_result.py index a225039..99f41f5 100644 --- a/tests/test_result.py +++ b/tests/test_result.py @@ -1,8 +1,11 @@ from contextlib import nullcontext import pytest +from hypothesis import given +from hypothesis import strategies as st from danom import Err, Ok, Result +from danom._utils import identity from tests.conftest import add_one @@ -120,3 +123,32 @@ def test_staticmethod_result_is_ok(monad, expected_result): def test_staticmethod_result_unwrap(monad, expected_result, expected_context): with expected_context: assert Result.result_unwrap(monad) == expected_result + + +@pytest.mark.parametrize( + ("monad", "expected_result"), + [pytest.param(Ok(Ok(Ok(1))), Ok(1)), pytest.param(Ok(Ok(Err())), Err())], +) +def test_flatten(monad, expected_result) -> None: + assert monad.flatten() == expected_result + + +st_results = st.integers().map(Ok) | st.text().map(Err) +st_nested_results = st.recursive( + st_results, lambda children: st.one_of(children.map(Ok)), max_leaves=10 +) + + +@given(monad=st_nested_results) +def test_flatten_idempotent(monad) -> None: + assert monad.flatten().flatten() == monad.flatten() + + +@given(monad=st_results) +def test_flatten_noop_for_flat_monad(monad) -> None: + assert monad.flatten() == monad + + +@given(monad=st_nested_results) +def test_and_then_flattens(monad): + assert monad.flatten() == monad.and_then(identity)