returns icon indicating copy to clipboard operation
returns copied to clipboard

can't compose @future_safe coroutines that specify picky exceptions

Open zed opened this issue 8 months ago • 3 comments

Given coroutines coro1, coro2:

@future_safe(exceptions=(ConnectionError,))
async def coro1(c: int | None) -> int:
    if c is None:
        raise ConnectionError("not connected")
    await asyncio.sleep(0)  # emulate I/O
    return c


@future_safe(exceptions=(ZeroDivisionError,))
async def coro2(n: int) -> float:
    await asyncio.sleep(0)  # emulate I/O
    return 1 / n

I want to combine them via bind: coro1(c).bind(coro2). It results in type error:

$ uvx --with 'returns[compatible-mypy]==0.25.0' mypy mre.py; ./mre.py
mre.py:30: error: Argument 1 to "bind" of "FutureResult" has incompatible type "Callable[[int], FutureResult[float, ZeroDivisionError]]"; expected "Callable[[int], KindN[FutureResult[Any, Any], float, ConnectionError, Any]]"  [arg-type]

where mre.py:

#!/usr/bin/env -S uv run --script
# /// script
# requires-python = ">=3.11"
# dependencies = [
#     "returns >=0.25.0",
# ]
# ///
from typing import assert_never
from returns.result import Success, Failure
from returns.io import IOSuccess, IOFailure
from returns.future import future_safe


@future_safe(exceptions=(ConnectionError,))
async def coro1(c: int | None) -> int:
    if c is None:
        raise ConnectionError("not connected")
    await asyncio.sleep(0)  # emulate I/O
    return c


@future_safe(exceptions=(ZeroDivisionError,))
async def coro2(n: int) -> float:
    await asyncio.sleep(0)  # emulate I/O
    return 1 / n


async def run() -> None:
    for c in [2, 0, None]:
        match await coro1(c).bind(coro2):
            case IOSuccess(Success(r)):
                assert r == 1 / 2, r
            case IOFailure(Failure(ZeroDivisionError(args=(msg,)))):
                assert msg == "division by zero", msg
            case IOFailure(Failure(ConnectionError(args=(msg,)))):
                assert msg == "not connected", msg
            case _ as unreachable:
                assert_never(unreachable)  # type: ignore[arg-type]


if __name__ == "__main__":
    import asyncio

    asyncio.run(run())

If picky exceptions are removed:

@future_safe
async def coro1(c: int | None) -> int: ...

@future_safe
async def coro2(n: int) -> float: ...

I get the desired behavior that coroutines are composed without type errors but these declarations catch too much.

zed avatar Mar 23 '25 06:03 zed

Will https://github.com/dry-python/returns/blob/master/returns/pointfree/unify.py work for you?

That's correct that you can't unify by default. You either can:

  1. Define common exception type like type MyError = ConnectionError | ZeroDivisionError and use it like x: IOResult[float, MyError] = await coro1(c).bind(coro2)
  2. Use IOResultE if you don't care about precise exceptions types
  3. Use unify :)

sobolevn avatar Mar 23 '25 07:03 sobolevn

match await unify(coro2)(coro1(c)): passes the type check. The issue is resolved. It would be perfect for my use-case if the type checking worked with lambdas. For now, it is a lot of unify(partial(Class.method, a=a, b=b, c=c))(container_with_self) that works but verbose.

I tried union type and it worked:

ErrorType = ConnectionError | ZeroDivisionError
Coro1Type = Callable[[int | None], FutureResult[int, ErrorType]]
Coro2Type = Callable[[int], FutureResult[float, ErrorType]]

but the coupling it introduces between coro1 and coro2 is not always desired.

Initially, I used FutureResultE that is too permissive.

zed avatar Mar 23 '25 13:03 zed

The type check passes with the original match await coro1(c).bind(coro2) code on https://github.com/dry-python/returns/pull/1975

uvx --with 'git+https://github.com/dry-python/returns.git@refs/pull/1975/head' --with mypy mypy mre.py; ./mre.py
Success: no issues found in 1 source file

No errors on lambdas too.

zed avatar Mar 24 '25 05:03 zed