Skip to content
Draft
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
7 changes: 7 additions & 0 deletions docs/source/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

Expand Down
11 changes: 11 additions & 0 deletions newsfragments/50.bugfix.rst
Original file line number Diff line number Diff line change
@@ -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.
55 changes: 55 additions & 0 deletions src/outcome/_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import abc
from typing import (
TYPE_CHECKING,
Any,
AsyncGenerator,
Awaitable,
Callable,
Expand Down Expand Up @@ -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,
Expand All @@ -46,30 +57,57 @@ 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:
exc = remove_tb_frames(exc, 1)
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]],
Expand All @@ -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:
Expand Down
54 changes: 52 additions & 2 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
52 changes: 50 additions & 2 deletions tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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)
29 changes: 29 additions & 0 deletions tests/type_tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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)

Expand All @@ -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]

Expand Down
Loading