Skip to content
Open
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
4 changes: 4 additions & 0 deletions newsfragments/47.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Remove reference to value/error when unwrapping outcome.
Provide a ``.peek()`` method to recieve wrapped
value or a copy of the wrapped error
without invalidating the outcome.
103 changes: 79 additions & 24 deletions src/outcome/_impl.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import abc
import copy
from typing import (
TYPE_CHECKING,
AsyncGenerator,
Expand Down Expand Up @@ -122,16 +123,23 @@ class Outcome(abc.ABC, Generic[ValueT]):
hashable.

"""
_unwrapped: bool = attr.ib(default=False, eq=False, init=False)

def _set_unwrapped(self) -> None:
if self._unwrapped:
raise AlreadyUsedError
object.__setattr__(self, '_unwrapped', True)
@abc.abstractmethod
def peek(self) -> ValueT:
"""Return the contained value or raise a copy of the contained
exception, without invalidating the outcome.

These two lines of code are almost equivalent::

x = fn(*args)
x = outcome.capture(fn, *args).peek()

"""
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't really get the use case for peek in a world where we aren't using it to rename the properties...

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rename the properties?

I'm happy to remove peek but I'd want everyone to agree

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what I understood, @TeamSpen210 mentioned them to force existing users to use .peek() s.t. existing users of .value and .error get typecheckers warning about it. (and my reading was that they would return an error value, not raise)

Since compatibility with existing Trio (given we don't have an upper bound) is probably more important, we can't do that -- in which case, having them basically duplicates the properties. Not sure if that's what @CoolCat467 / @oremanj read from earlier comments.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that was my thought, they'd act the same as the property, but be a different name so you get instant feedback (type checker or runtime exception).


@abc.abstractmethod
def unwrap(self) -> ValueT:
"""Return or raise the contained value or exception.
"""Return or raise the contained value or exception, and invalidate
the outcome.

These two lines of code are equivalent::

Expand Down Expand Up @@ -170,23 +178,36 @@ class Value(Outcome[ValueT], Generic[ValueT]):

"""

value: ValueT = attr.ib()
"""The contained value."""
_value: ValueT = attr.ib()

def __repr__(self) -> str:
return f'Value({self.value!r})'
try:
return f'Value({self._value!r})'
except AttributeError:
return 'Value(<AlreadyUsed>)'

def unwrap(self) -> ValueT:
self._set_unwrapped()
def peek(self) -> ValueT:
return self.value

def unwrap(self) -> ValueT:
v = self.value
object.__delattr__(self, "_value")
return v

def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT:
self._set_unwrapped()
return gen.send(self.value)
return gen.send(self.unwrap())

async def asend(self, agen: AsyncGenerator[ResultT, ValueT]) -> ResultT:
self._set_unwrapped()
return await agen.asend(self.value)
return await agen.asend(self.unwrap())

@property
def value(self) -> ValueT:
"""The contained value."""
try:
return self._value
except AttributeError as e:
pass
raise AlreadyUsedError


@final
Expand All @@ -196,19 +217,46 @@ class Error(Outcome[NoReturn]):

"""

error: BaseException = attr.ib(
_error: BaseException = attr.ib(
validator=attr.validators.instance_of(BaseException)
)
"""The contained exception object."""

def __repr__(self) -> str:
return f'Error({self.error!r})'
try:
return f'Error({self._error!r})'
except AttributeError:
return 'Error(<AlreadyUsed>)'

def _unwrap_error(self) -> BaseException:
v = self.error
object.__delattr__(self, "_error")
return v

def peek(self) -> NoReturn:
# Tracebacks show the 'raise' line below out of context, so let's give
# this variable a name that makes sense out of context.
captured_error = copy.copy(self.error)
try:
raise captured_error
finally:
# We want to avoid creating a reference cycle here. Python does
# collect cycles just fine, so it wouldn't be the end of the world
# if we did create a cycle, but the cyclic garbage collector adds
# latency to Python programs, and the more cycles you create, the
# more often it runs, so it's nicer to avoid creating them in the
# first place. For more details see:
#
# https://github.com/python-trio/trio/issues/1770
#
# In particuar, by deleting this local variables from the 'peek'
# methods frame, we avoid the 'captured_error' object's
# __traceback__ from indirectly referencing 'captured_error'.
del captured_error, self

def unwrap(self) -> NoReturn:
self._set_unwrapped()
# Tracebacks show the 'raise' line below out of context, so let's give
# this variable a name that makes sense out of context.
captured_error = self.error
captured_error = self._unwrap_error()
try:
raise captured_error
finally:
Expand All @@ -227,12 +275,19 @@ def unwrap(self) -> NoReturn:
del captured_error, self

def send(self, gen: Generator[ResultT, NoReturn, object]) -> ResultT:
self._set_unwrapped()
return gen.throw(self.error)
return gen.throw(self._unwrap_error())

async def asend(self, agen: AsyncGenerator[ResultT, NoReturn]) -> ResultT:
self._set_unwrapped()
return await agen.athrow(self.error)
return await agen.athrow(self._unwrap_error())

@property
def error(self) -> BaseException:
"""The contained exception object."""
try:
return self._error
except AttributeError:
pass
raise AlreadyUsedError


# A convenience alias to a union of both results, allowing exhaustiveness checking.
Expand Down
7 changes: 7 additions & 0 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ async def add(x, y):

v = await outcome.acapture(add, 3, y=4)
assert v == Value(7)
assert v.peek() == 7

async def raise_ValueError(x):
await asyncio.sleep(0)
Expand All @@ -24,6 +25,12 @@ async def raise_ValueError(x):
e = await outcome.acapture(raise_ValueError, 9)
assert type(e.error) is ValueError
assert e.error.args == (9,)
with pytest.raises(ValueError) as exc_info:
e.peek()
with pytest.raises(ValueError) as exc_info2:
e.unwrap()

assert exc_info.value is not exc_info2.value


async def test_asend():
Expand Down
19 changes: 16 additions & 3 deletions tests/test_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@
def test_Outcome():
v = Value(1)
assert v.value == 1
assert v.unwrap() == 1
assert repr(v) == "Value(1)"
assert v.peek() == 1
assert v.unwrap() == 1
assert repr(v) == "Value(<AlreadyUsed>)"

with pytest.raises(AlreadyUsedError):
v.unwrap()
Expand All @@ -21,11 +23,17 @@ def test_Outcome():
exc = RuntimeError("oops")
e = Error(exc)
assert e.error is exc
with pytest.raises(RuntimeError):
assert repr(e) == f"Error({exc!r})"
with pytest.raises(RuntimeError) as exc_info:
e.peek()
with pytest.raises(RuntimeError) as exc_info2:
e.unwrap()

assert exc_info.value is not exc_info2.value

with pytest.raises(AlreadyUsedError):
e.unwrap()
assert repr(e) == f"Error({exc!r})"
assert repr(e) == "Error(<AlreadyUsed>)"

e = Error(exc)
with pytest.raises(TypeError):
Expand Down Expand Up @@ -92,6 +100,7 @@ def add(x, y):

v = outcome.capture(add, 2, y=3)
assert type(v) == Value
assert v.peek() == 5
assert v.unwrap() == 5

def raise_ValueError(x):
Expand All @@ -101,6 +110,10 @@ def raise_ValueError(x):
assert type(e) == Error
assert type(e.error) is ValueError
assert e.error.args == ("two",)
with pytest.raises(ValueError):
e.peek()
with pytest.raises(ValueError):
e.unwrap()


def test_inheritance():
Expand Down
Loading