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
6 changes: 6 additions & 0 deletions .github/workflows/ci_tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,19 @@ jobs:
tests:
name: python
runs-on: ubuntu-latest
strategy:
matrix:
python-version:
- "3.12"
- "3.14"

steps:
- uses: actions/checkout@v6

- name: Install uv
uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
enable-cache: true
cache-dependency-glob: "uv.lock"

Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "danom"
version = "0.14.1"
version = "0.15.0"
description = "Functional streams and monads"
readme = "README.md"
license = "MIT"
Expand Down Expand Up @@ -36,7 +36,7 @@ dev = [
"pytest-asyncio>=1.3.0",
"pytest-codspeed>=4.2.0",
"pytest-cov>=7.0.0",
"repo-mapper-rs>=0.4.0",
"repo-mapper-rs>=0.4.1",
"ruff>=0.14.6",
"sphinx>=9.0.4",
"ty>=0.0.8",
Expand Down
52 changes: 43 additions & 9 deletions src/danom/_safe.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import functools
import traceback
from collections.abc import Callable
from typing import Concatenate, ParamSpec, TypeVar
from typing import Concatenate, ParamSpec, TypeVar, overload

from danom._result import Err, Ok, Result

Expand All @@ -10,8 +10,22 @@
U = TypeVar("U")
E = TypeVar("E")

type ExceptionType = type[Exception] | tuple[type[Exception], ...]

def safe[**P, U](func: Callable[P, U]) -> Callable[P, Result[U, Exception]]:

@overload
def safe[**P, U](func: Callable[P, U]) -> Callable[P, Result[U, Exception]]: ...


@overload
def safe(
func: None = None, *, errors: ExceptionType = Exception
) -> Callable[[Callable[P, U]], Callable[P, Result[U, Exception]]]: ...


def safe[**P, U](
func: Callable[P, U] | None = None, *, errors: ExceptionType = Exception
) -> Callable[P, Result[U, Exception]] | Callable[..., Callable[P, Result[U, Exception]]]:
"""Decorator for functions that wraps the function in a try except returns `Ok` on success else `Err`.

.. code-block:: python
Expand All @@ -23,16 +37,36 @@ def add_one(a: int) -> int:
return a + 1

add_one(1) == Ok(inner=2)


Only catch a single error type or subset of error types by passing in an error type to catch.

.. code-block:: python

from danom import safe

@safe(errors=ZeroDivisionError)
def div(a: int, b: int) -> float:
return a / b


div(2, 0) == Err(error=ZeroDivisionError('division by zero'))
div(2, "")
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
"""

@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[U, Exception]:
try:
return Ok(func(*args, **kwargs))
except Exception as e: # noqa: BLE001
return Err(error=e, input_args=(args, kwargs), traceback=traceback.format_exc()) # ty: ignore[invalid-return-type]
def decorator(func: Callable[P, U]) -> Callable[P, Result[U, Exception]]:
@functools.wraps(func)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> Result[U, Exception]:
try:
return Ok(func(*args, **kwargs))
except errors as e:
return Err(error=e, input_args=(args, kwargs), traceback=traceback.format_exc()) # ty: ignore[invalid-return-type]

return wrapper
return wrapper

return decorator(func) if func is not None else decorator


def safe_method[T, **P, U](
Expand Down
16 changes: 11 additions & 5 deletions src/danom/_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
U = TypeVar("U")
E = TypeVar("E")
P = ParamSpec("P")
S = TypeVar("S", bound="_BaseStream")

MapFn = Callable[P, U]
FilterFn = Callable[P, bool]
Expand Down Expand Up @@ -68,6 +69,11 @@ def fold(
self, initial: T, fn: Callable[[T, U], T], *, workers: int = 1, use_threads: bool = False
) -> T: ...

@abstractmethod
def sequence(
self, *, workers: int = 1, use_threads: bool = False
) -> Result[S, E] | Either[S, E]: ...

@abstractmethod
def collect(self) -> tuple[U, ...]: ...

Expand Down Expand Up @@ -330,15 +336,15 @@ def partition(
pos, neg = [], []

for x in seq_tuple:
if fn(x):
pos.append(x)
else:
if not fn(x):
neg.append(x)
continue
pos.append(x)
return (Stream.from_iterable(pos), Stream.from_iterable(neg))

def sequence(
self, *, workers: int = 1, use_threads: bool = False
) -> Result[T, E] | Either[T, E]:
self: Stream[T], *, workers: int = 1, use_threads: bool = False
) -> Result[Stream[T], E] | Either[Stream[T], E]:
"""Convert a ``Stream`` of ``Result`` or ``Either`` monads to a monad of Stream

.. doctest::
Expand Down
33 changes: 32 additions & 1 deletion tests/test_safe.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
from contextlib import nullcontext
from functools import partial

import pytest

from danom._result import Err, Ok
from danom._safe import safe
from tests.conftest import (
REPO_ROOT,
Adder,
Expand Down Expand Up @@ -65,7 +67,7 @@ def test_traceback():

expected_lines = [
"Traceback (most recent call last):",
' File "./src/danom/_safe.py", line 31, in wrapper',
' File "./src/danom/_safe.py", line 63, in wrapper',
" return Ok(func(*args, **kwargs))",
' File "./tests/conftest.py", line 117, in div_zero',
" return x / 0",
Expand All @@ -85,3 +87,32 @@ def test_traceback():
def test_safe_on_method():
cls = Adder()
assert cls.safe_add(2, 2) == Ok(4)


@pytest.mark.parametrize(
("a", "b", "errors", "expected_result", "expected_context"),
[
pytest.param(
1, 1, Exception, True, nullcontext(), id="Does nothing if the function returns ok"
),
pytest.param(
1,
0,
ZeroDivisionError,
False,
nullcontext(),
id="Returns Err if the err type is in the passed in list of errors",
),
pytest.param(
1,
"",
ZeroDivisionError,
True,
pytest.raises(TypeError),
id="Raises exception if the exception type isn't in the list of errors",
),
],
)
def test_safe_subset_of_errors(a, b, errors, expected_result, expected_context) -> None:
with expected_context:
assert safe(errors=errors)(lambda a, b: a / b)(a, b).is_ok() is expected_result
20 changes: 10 additions & 10 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.