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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

# API Reference

[danom API docs](https://second-ed.github.io/danom/)


## Stream

An immutable lazy iterator with functional operations.
Expand Down
4 changes: 4 additions & 0 deletions docs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ deploy:
fi
@cd docs-build && git worktree add -f html gh-pages || true


local:
@uv run sphinx-apidoc -o source src/danom/ --separate ; uv run sphinx-build source docs-build/html

# Catch-all target: route all unknown targets to Sphinx using the new
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
%: Makefile
Expand Down
8 changes: 6 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "danom"
version = "0.8.1"
version = "0.8.2"
description = "Functional streams and monads"
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -32,6 +32,7 @@ dev = [
"repo-mapper-rs>=0.3.0",
"ruff>=0.14.6",
"sphinx>=9.0.4",
"ty>=0.0.8",
]

[tool.coverage.run]
Expand All @@ -46,4 +47,7 @@ skip_covered = true
fail_under = 95

[tool.pytest]
# addopts = ["--codspeed"]
# addopts = ["--codspeed"]

[tool.ty.src]
include = ["src", "tests"]
1 change: 1 addition & 0 deletions ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ ignore = [
"PT011", # Don't care about exception messages
"ANN" # Who typehints their tests?
]
"conf.py" = ["ALL"]
[format]
quote-style = "double"
indent-style = "space"
Expand Down
13 changes: 9 additions & 4 deletions src/danom/_new_type.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

import inspect
from collections.abc import Callable, Sequence
from typing import Self
from functools import wraps
from typing import TypeVar

import attrs

Expand Down Expand Up @@ -72,7 +73,7 @@ def _create_forward_methods(base_type: type) -> dict[str, Callable]:
continue

def make_forwarder(name: str) -> Callable:
def method[T](self: Self, *args: tuple, **kwargs: dict) -> T:
def method[T](self, *args: tuple, **kwargs: dict) -> T: # noqa: ANN001
return getattr(self.inner, name)(*args, **kwargs)

method.__name__ = name
Expand All @@ -96,11 +97,12 @@ def _callables_to_kwargs(


def _validate_bool_func[T](
bool_fn: Callable[[...], bool],
bool_fn: Callable[..., bool],
) -> Callable[[attrs.AttrsInstance, attrs.Attribute, T], None]:
if not isinstance(bool_fn, Callable):
raise TypeError("provided boolean function must be callable")

@wraps(bool_fn)
def wrapper(_instance: attrs.AttrsInstance, attribute: attrs.Attribute, value: T) -> None:
if not bool_fn(value):
raise ValueError(
Expand All @@ -110,7 +112,10 @@ def wrapper(_instance: attrs.AttrsInstance, attribute: attrs.Attribute, value: T
return wrapper


def _to_list(value: Callable | Sequence[Callable]) -> list[Callable]:
C = TypeVar("C", bound=Callable[..., object])


def _to_list(value: C | Sequence[C] | None) -> list[C]:
if value is None:
return []
if isinstance(value, Sequence) and not isinstance(value, (str, bytes)):
Expand Down
72 changes: 43 additions & 29 deletions src/danom/_result.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,21 +6,32 @@
from typing import (
Any,
Literal,
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)
P = ParamSpec("P")

@attrs.define
class Result[T, U](ABC):
Mappable = Callable[P, U_co]
ResultReturnType = TypeVar("ResultReturnType", bound="Result[U_co, E_co]")
Bindable = Callable[P, ResultReturnType]


@attrs.define(frozen=True)
class Result(ABC):
"""`Result` monad. Consists of `Ok` and `Err` for successful and failed operations respectively.
Each monad is a frozen instance to prevent further mutation.
"""

@classmethod
def unit(cls, inner: T) -> Ok[T]:
"""Unit method. Given an item of type `T` return `Ok(T)`
def unit(cls, inner: T_co) -> Ok[T_co]:
"""Unit method. Given an item of type `T_co` return `Ok(T_co)`

.. code-block:: python

Expand All @@ -47,7 +58,7 @@ def is_ok(self) -> bool:
...

@abstractmethod
def map(self, func: Callable[[T], U], **kwargs: dict) -> Result[U]:
def map(self, func: Mappable, **kwargs: P.kwargs) -> ResultReturnType:
"""Pipe a pure function and wrap the return value with `Ok`.
Given an `Err` will return self.

Expand All @@ -61,7 +72,7 @@ def map(self, func: Callable[[T], U], **kwargs: dict) -> Result[U]:
...

@abstractmethod
def and_then(self, func: Callable[[T], Result[U]], **kwargs: dict) -> Result[U]:
def and_then(self, func: Bindable, **kwargs: P.kwargs) -> ResultReturnType:
"""Pipe another function that returns a monad. For `Err` will return original error.

.. code-block:: python
Expand All @@ -76,7 +87,7 @@ def and_then(self, func: Callable[[T], Result[U]], **kwargs: dict) -> Result[U]:
...

@abstractmethod
def unwrap(self) -> T:
def unwrap(self) -> T_co:
"""Unwrap the `Ok` monad and get the inner value.
Unwrap the `Err` monad will raise the inner error.

Expand All @@ -92,9 +103,7 @@ def unwrap(self) -> T:
...

@abstractmethod
def match(
self, if_ok_func: Callable[[T], Result], if_err_func: Callable[[T], Result]
) -> Result:
def match(self, if_ok_func: Mappable, if_err_func: Mappable) -> ResultReturnType:
"""Map `ok_func` to `Ok` and `err_func` to `Err`

.. code-block:: python
Expand All @@ -108,35 +117,37 @@ def match(
...

def __class_getitem__(cls, _params: tuple) -> Self:
return cls
return cls # ty: ignore[invalid-return-type]


@attrs.define(frozen=True, hash=True)
class Ok[T, U](Result):
class Ok[T_co](Result):
inner: Any = attrs.field(default=None)

def is_ok(self) -> Literal[True]:
return True

def map(self, func: Callable[[T], U], **kwargs: dict) -> Result[U]:
def map(self, func: Mappable, **kwargs: P.kwargs) -> Ok[U_co]:
return Ok(func(self.inner, **kwargs))

def and_then(self, func: Callable[[T], Result[U]], **kwargs: dict) -> Result[U]:
def and_then(self, func: Bindable, **kwargs: P.kwargs) -> ResultReturnType:
return func(self.inner, **kwargs)

def unwrap(self) -> T:
def unwrap(self) -> T_co:
return self.inner

def match(
self, if_ok_func: Callable[[T], Result], _if_err_func: Callable[[T], Result]
) -> Result:
def match(self, if_ok_func: Mappable, if_err_func: Mappable) -> ResultReturnType: # noqa: ARG002
return if_ok_func(self.inner)


@attrs.define(frozen=True, hash=True)
class Err[T, U, E](Result):
error: E | Exception | None = attrs.field(default=None)
input_args: tuple[T] = attrs.field(default=None, repr=False)
SafeArgs = tuple[tuple[Any, ...], dict[str, Any]]
SafeMethodArgs = tuple[object, tuple[Any, ...], dict[str, Any]]


@attrs.define(frozen=True)
class Err[E_co](Result):
error: E_co | Exception = attrs.field(default=None)
input_args: tuple[()] | SafeArgs | SafeMethodArgs = attrs.field(default=(), repr=False)
details: list[dict[str, Any]] = attrs.field(factory=list, init=False, repr=False)

def __attrs_post_init__(self) -> None:
Expand All @@ -162,28 +173,31 @@ def _extract_details(self, tb: TracebackType | None) -> list[dict[str, Any]]:
def is_ok(self) -> Literal[False]:
return False

def map(self, _: Callable[[T], U], **_kwargs: dict) -> Result[U]:
def map(self, func: Mappable, **kwargs: P.kwargs) -> Err[E_co]: # noqa: ARG002
return self

def and_then(self, _: Callable[[T], Result[U]], **_kwargs: dict) -> Self:
def and_then(self, func: Bindable, **kwargs: P.kwargs) -> Err[E_co]: # noqa: ARG002
return self

def unwrap(self) -> None:
if isinstance(self.error, Exception):
raise self.error
raise ValueError(f"Err does not have a caught error to raise: {self.error = }")

def match(
self, _if_ok_func: Callable[[T], Result], if_err_func: Callable[[T], Result]
) -> Result:
def match(self, if_ok_func: Mappable, if_err_func: Mappable) -> ResultReturnType: # noqa: ARG002
return if_err_func(self.error)

def __eq__(self, other: Err) -> bool:
def __eq__(self, other: object) -> bool:
if not isinstance(other, Err):
return False

return all(
(
isinstance(other, Err),
type(self.error) is type(other.error),
str(self.error) == str(other.error),
self.input_args == other.input_args,
)
)

def __hash__(self) -> int:
return hash(f"{type(self.error)}{self.error}{self.input_args}")
13 changes: 5 additions & 8 deletions src/danom/_safe.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,13 @@
import functools
from collections.abc import Callable
from typing import (
ParamSpec,
Self,
)
from typing import ParamSpec

from danom._result import Err, Ok, Result

P = ParamSpec("P")


def safe[T, U](func: Callable[[T], U]) -> Callable[[T], Result]:
def safe[U, E](func: Callable[..., U]) -> Callable[..., Result[U, E]]:
"""Decorator for functions that wraps the function in a try except returns `Ok` on success else `Err`.

.. code-block:: python
Expand All @@ -25,7 +22,7 @@ def add_one(a: int) -> int:
"""

@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result:
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[U, E]:
try:
return Ok(func(*args, **kwargs))
except Exception as e: # noqa: BLE001
Expand All @@ -34,7 +31,7 @@ def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result:
return wrapper


def safe_method[T, U, E](func: Callable[[T], U]) -> Callable[[T], Result[U, E]]:
def safe_method[U, E](func: Callable[..., U]) -> Callable[..., Result[U, E]]:
"""The same as `safe` except it forwards on the `self` of the class instance to the wrapped function.

.. code-block:: python
Expand All @@ -53,7 +50,7 @@ def add_one(self, a: int) -> int:
"""

@functools.wraps(func)
def wrapper(self: Self, *args: P.args, **kwargs: P.kwargs) -> Result[U, E]:
def wrapper(self, *args: P.args, **kwargs: P.kwargs) -> Result[U, E]: # noqa: ANN001
try:
return Ok(func(self, *args, **kwargs))
except Exception as e: # noqa: BLE001
Expand Down
Loading