diff --git a/docs/source/tutorial.rst b/docs/source/tutorial.rst index 1c2d6c2..088169b 100644 --- a/docs/source/tutorial.rst +++ b/docs/source/tutorial.rst @@ -25,6 +25,13 @@ which, like before, is the same as:: x = await f(*args, **kwargs) +When the callable is passed positionally to :func:`capture` or +:func:`acapture`, all keyword arguments (including ``sync_fn`` and +``async_fn``) are forwarded to it. For compatibility, the callable can also +be passed as ``capture(sync_fn=f, ...)`` or ``acapture(async_fn=f, ...)`` +when there are no positional arguments; that keyword selects the callable +and the remaining keywords are forwarded. + An Outcome object can only be unwrapped once. A second attempt would raise an :class:`AlreadyUsedError`. diff --git a/newsfragments/50.bugfix.rst b/newsfragments/50.bugfix.rst new file mode 100644 index 0000000..a9754d0 --- /dev/null +++ b/newsfragments/50.bugfix.rst @@ -0,0 +1,11 @@ +Allow ``capture(fn, sync_fn=value)`` and +``await acapture(fn, async_fn=value)`` to forward those keywords to the +positional callable. Previously valid calls, including selecting the callable +by keyword when no positional arguments are supplied, remain supported. + +Runtime signatures and annotations now expose ``*args: Any, **kwargs: Any`` +with return annotation ``Value[Any] | Error``; reflective binding tools may +observe different behavior, while static overloads retain precise typing. +Malformed ``acapture()`` calls now raise the missing-callable ``TypeError`` +when awaited rather than when the coroutine is constructed; malformed-call +tracebacks and diagnostic precedence can also differ. diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 004b72d..7959518 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -3,6 +3,7 @@ import abc from typing import ( TYPE_CHECKING, + Any, AsyncGenerator, Awaitable, Callable, @@ -36,6 +37,16 @@ def final(func): @overload def capture( # NoReturn = raises exception, so we should get an error. + sync_fn: Callable[ArgsT, NoReturn], + /, + *args: ArgsT.args, + **kwargs: ArgsT.kwargs, +) -> Error: + ... + + +@overload +def capture( sync_fn: Callable[ArgsT, NoReturn], *args: ArgsT.args, **kwargs: ArgsT.kwargs, @@ -46,23 +57,40 @@ def capture( @overload def capture( sync_fn: Callable[ArgsT, ResultT], + /, *args: ArgsT.args, **kwargs: ArgsT.kwargs, ) -> Value[ResultT] | Error: ... +@overload def capture( sync_fn: Callable[ArgsT, ResultT], *args: ArgsT.args, **kwargs: ArgsT.kwargs, ) -> Value[ResultT] | Error: + ... + + +def capture(*args: Any, **kwargs: Any) -> Value[Any] | Error: """Run ``sync_fn(*args, **kwargs)`` and capture the result. Returns: Either a :class:`Value` or :class:`Error` as appropriate. """ + # A positional callable leaves every keyword available for forwarding. + # Without one, accept the original callable-by-keyword convention. + if args: + sync_fn, args = args[0], args[1:] + elif "sync_fn" in kwargs: + sync_fn = kwargs.pop("sync_fn") + else: + raise TypeError( + "capture() missing 1 required positional argument: 'sync_fn'" + ) + try: return Value(sync_fn(*args, **kwargs)) except BaseException as exc: @@ -70,6 +98,16 @@ def capture( return Error(exc) +@overload +async def acapture( + async_fn: Callable[ArgsT, Awaitable[NoReturn]], + /, + *args: ArgsT.args, + **kwargs: ArgsT.kwargs, +) -> Error: + ... + + @overload async def acapture( async_fn: Callable[ArgsT, Awaitable[NoReturn]], @@ -82,23 +120,40 @@ async def acapture( @overload async def acapture( async_fn: Callable[ArgsT, Awaitable[ResultT]], + /, *args: ArgsT.args, **kwargs: ArgsT.kwargs, ) -> Value[ResultT] | Error: ... +@overload async def acapture( async_fn: Callable[ArgsT, Awaitable[ResultT]], *args: ArgsT.args, **kwargs: ArgsT.kwargs, ) -> Value[ResultT] | Error: + ... + + +async def acapture(*args: Any, **kwargs: Any) -> Value[Any] | Error: """Run ``await async_fn(*args, **kwargs)`` and capture the result. Returns: Either a :class:`Value` or :class:`Error` as appropriate. """ + # A positional callable leaves every keyword available for forwarding. + # Without one, accept the original callable-by-keyword convention. + if args: + async_fn, args = args[0], args[1:] + elif "async_fn" in kwargs: + async_fn = kwargs.pop("async_fn") + else: + raise TypeError( + "acapture() missing 1 required positional argument: 'async_fn'" + ) + try: return Value(await async_fn(*args, **kwargs)) except BaseException as exc: diff --git a/tests/test_async.py b/tests/test_async.py index 5ff95fd..0166453 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -48,13 +48,63 @@ async def my_agen_func(): await my_agen.asend(None) -async def test_traceback_frame_removal(): +@pytest.mark.parametrize("raises", [False, True]) +async def test_acapture_accepts_legacy_callable_keyword(raises): + value = object() + error = ValueError("captured") + + async def fn(*, value): + await asyncio.sleep(0) + if raises: + raise error + return value + + result = await outcome.acapture(async_fn=fn, value=value) + if raises: + assert isinstance(result, Error) + assert result.error is error + else: + assert result.unwrap() is value + + +@pytest.mark.parametrize("raises", [False, True]) +async def test_acapture_forwards_async_fn_keyword(raises): + value = object() + error = ValueError("captured") + + async def fn(*, async_fn): + await asyncio.sleep(0) + assert async_fn is value + if raises: + raise error + return async_fn + + result = await outcome.acapture(fn, async_fn=value) + if raises: + assert isinstance(result, Error) + assert result.error is error + else: + assert result.unwrap() is value + + +@pytest.mark.parametrize("legacy_keyword", [False, True]) +async def test_traceback_frame_removal(legacy_keyword): async def raise_ValueError(x): raise ValueError(x) - e = await outcome.acapture(raise_ValueError, 'abc') + if legacy_keyword: + e = await outcome.acapture(async_fn=raise_ValueError, x='abc') + else: + e = await outcome.acapture(raise_ValueError, 'abc') with pytest.raises(ValueError) as exc_info: e.unwrap() frames = traceback.extract_tb(exc_info.value.__traceback__) functions = [function for _, _, function, _ in frames] assert functions[-2:] == ['unwrap', 'raise_ValueError'] + + +@pytest.mark.parametrize("kwargs", [{}, {"unrelated": 1}]) +async def test_acapture_requires_callable(kwargs): + with pytest.raises(TypeError, + match="missing 1 required positional argument"): + await outcome.acapture(**kwargs) diff --git a/tests/test_sync.py b/tests/test_sync.py index 855d776..2799e59 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -108,11 +108,52 @@ def test_inheritance(): assert issubclass(Error, outcome.Outcome) -def test_traceback_frame_removal(): +@pytest.mark.parametrize("raises", [False, True]) +def test_capture_accepts_legacy_callable_keyword(raises): + value = object() + error = ValueError("captured") + + def fn(*, value): + if raises: + raise error + return value + + result = outcome.capture(sync_fn=fn, value=value) + if raises: + assert isinstance(result, Error) + assert result.error is error + else: + assert result.unwrap() is value + + +@pytest.mark.parametrize("raises", [False, True]) +def test_capture_forwards_sync_fn_keyword(raises): + value = object() + error = ValueError("captured") + + def fn(*, sync_fn): + assert sync_fn is value + if raises: + raise error + return sync_fn + + result = outcome.capture(fn, sync_fn=value) + if raises: + assert isinstance(result, Error) + assert result.error is error + else: + assert result.unwrap() is value + + +@pytest.mark.parametrize("legacy_keyword", [False, True]) +def test_traceback_frame_removal(legacy_keyword): def raise_ValueError(x): raise ValueError(x) - e = outcome.capture(raise_ValueError, 'abc') + if legacy_keyword: + e = outcome.capture(sync_fn=raise_ValueError, x='abc') + else: + e = outcome.capture(raise_ValueError, 'abc') with pytest.raises(ValueError) as exc_info: e.unwrap() frames = traceback.extract_tb(exc_info.value.__traceback__) @@ -137,3 +178,10 @@ def test_Error_unwrap_does_not_create_reference_cycles(): unwrap_frame = exc.__traceback__.tb_next.tb_frame assert unwrap_frame.f_code.co_name == "unwrap" assert unwrap_frame.f_locals == {} + + +@pytest.mark.parametrize("kwargs", [{}, {"unrelated": 1}]) +def test_capture_requires_callable(kwargs): + with pytest.raises(TypeError, + match="missing 1 required positional argument"): + outcome.capture(**kwargs) diff --git a/tests/type_tests.py b/tests/type_tests.py index bb442de..9b4050a 100644 --- a/tests/type_tests.py +++ b/tests/type_tests.py @@ -111,12 +111,25 @@ def sync_one(param: float) -> int: return round(param) +def sync_keyword(*, sync_fn: float) -> int: + return round(sync_fn) + + def sync_capture_test() -> None: """Test synchronous behaviour.""" assert_type(capture(sync_none), Union[Value[bool], Error]) assert_type(capture(sync_one, 3.14), Union[Value[int], Error]) assert_type(capture(sync_one, param=3.14), Union[Value[int], Error]) assert_type(capture(sync_raises), Error) + assert_type(capture(sync_keyword, sync_fn=3.14), Union[Value[int], Error]) + assert_type(capture(sync_fn=sync_none), Union[Value[bool], Error]) + assert_type( + capture(sync_fn=sync_one, param=3.14), Union[Value[int], Error] + ) + assert_type(capture(sync_fn=sync_raises), Error) + capture(sync_fn=sync_one) # type: ignore[call-overload] + capture(sync_fn=sync_one, param="bad") # type: ignore[call-overload] + capture(sync_keyword, sync_fn="bad") # type: ignore[call-overload] capture(sync_one) # type: ignore[call-overload] capture(sync_none, 1, 2) # type: ignore[call-overload] @@ -156,6 +169,10 @@ async def async_one(param: float) -> int: return round(param) +async def async_keyword(*, async_fn: float) -> int: + return round(async_fn) + + async def async_generator_one() -> AsyncGenerator[int, str]: assert_type((yield 5), str) @@ -172,6 +189,18 @@ async def async_capture_test() -> None: await acapture(async_one, param=3.14), Union[Value[int], Error] ) assert_type(await acapture(async_raises), Error) + assert_type( + await acapture(async_keyword, async_fn=3.14), Union[Value[int], Error] + ) + assert_type(await acapture(async_fn=async_none), Union[Value[bool], Error]) + assert_type( + await acapture(async_fn=async_one, param=3.14), + Union[Value[int], Error] + ) + assert_type(await acapture(async_fn=async_raises), Error) + await acapture(async_fn=async_one) # type: ignore[call-overload] + await acapture(async_fn=async_one, param=0j) # type: ignore[call-overload] + await acapture(async_keyword, async_fn=0j) # type: ignore[call-overload] capture(async_one) # type: ignore[call-overload] capture(async_none, 1, 2) # type: ignore[call-overload]