Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
4 changes: 4 additions & 0 deletions src/danom/__init__.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
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
from danom._stream import Stream
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",
Expand Down
213 changes: 213 additions & 0 deletions src/danom/_either.py
Original file line number Diff line number Diff line change
@@ -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
84 changes: 84 additions & 0 deletions tests/test_either.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading