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
10 changes: 5 additions & 5 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,25 +1,25 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.5.26
rev: 0.11.2
hooks:
- id: uv-lock
- repo: local
hooks:
- id: ruff
name: ruff
entry: ruff check
language: system
language: python
types_or: [ python, pyi, jupyter ]
args: [ --fix ]
- id: ruff
name: ruff
entry: ruff format
language: system
language: python
types_or: [ python, pyi, jupyter ]
- id: ty
name: ty check
entry: uv run ty check
language: system
language: python
pass_filenames: false
always_run: true
- id: pytest
Expand All @@ -31,7 +31,7 @@ repos:
- id: repo-map
name: repo_map
entry: uv run -m repo_mapper
language: system
language: python
pass_filenames: false
args:
- --repo-root
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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "danom"
version = "0.11.3"
version = "0.12.0"
description = "Functional streams and monads"
readme = "README.md"
license = "MIT"
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
211 changes: 211 additions & 0 deletions src/danom/_either.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
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`` return ``Right(T)``

.. 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(1).map(add_one) == Left(1)
"""
...

@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(TypeError()).map_err(type_err_to_value_err) == Left(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 inner.

.. code-block:: python

from danom import Left, Right

Right(1).and_then(add_one) == Right(2)
Right(1).and_then(raise_err) == Left(TypeError())
Left(TypeError()).and_then(add_one) == Left(TypeError())
Left(TypeError()).and_then(raise_value_err) == Left(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(TypeError()).or_else(replace_err_with_zero) == Right(0)
"""
...

@abstractmethod
def unwrap(self) -> T_co:
"""Unwrap the `Right` or ``Left`` monad to get the inner value.

.. 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` or ``Left`` monad to get the inner value.

.. 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
13 changes: 5 additions & 8 deletions src/danom/_new_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,14 @@ def new_type( # noqa: ANN202
>>> ValidBalance(20.0) == ValidBalance(inner=20.0)
True

Unlike an inherited class, the type will not return `True` for an isinstance check.
Unlike an inherited class, the type will not return ``True`` for an isinstance check.

.. code-block:: python

isinstance(ValidBalance(20.0), ValidBalance) == True
isinstance(ValidBalance(20.0), float) == False

The methods of the given `base_type` will be forwarded to the specialised type.
The methods of the given ``base_type`` will be forwarded to the specialised type.
Alternatively the map method can be used to return a new type instance with the transformation.

.. code-block:: python
Expand All @@ -56,11 +56,11 @@ def has_len(email: str) -> bool:
kwargs = _callables_to_kwargs(base_type, validators, converters)

@attrs.define(frozen=frozen, eq=True, hash=frozen)
class _Wrapper:
class _Wrapper[T]:
inner: T = attrs.field(**kwargs) # ty: ignore[no-matching-overload]

def map(self, func: Callable[[T], T]) -> Self:
return self.__class__(func(self.inner)) # ty: ignore[invalid-argument-type]
return self.__class__(func(self.inner))

locals().update(_create_forward_methods(base_type))

Expand Down Expand Up @@ -118,10 +118,7 @@ def wrapper(_instance: attrs.AttrsInstance, attribute: attrs.Attribute, value: T
return wrapper


C = TypeVar("C", bound=Callable[P, object])


def _to_list(value: C | Sequence[C] | None) -> list[C]:
def _to_list[C: Callable[..., object]](value: C | Sequence[C] | None) -> list[C]:
if value is None:
return []

Expand Down
Loading
Loading