From 08b08df9af3f6b406723f0861fbd933830a67b6d Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 22 Mar 2025 11:35:07 +0000 Subject: [PATCH 01/15] remove instances from outcome on unwrap --- src/outcome/_impl.py | 58 ++++++++++++++++++++++++++++---------------- tests/test_sync.py | 6 +++-- 2 files changed, 41 insertions(+), 23 deletions(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 004b72d..3c287bd 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -122,13 +122,6 @@ 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 unwrap(self) -> ValueT: """Return or raise the contained value or exception. @@ -174,19 +167,29 @@ class Value(Outcome[ValueT], Generic[ValueT]): """The contained value.""" def __repr__(self) -> str: - return f'Value({self.value!r})' + try: + return f'Value({self.value!r})' + except AttributeError: + return f'Value()' def unwrap(self) -> ValueT: - self._set_unwrapped() - return self.value + try: + v = self.value + except AttributeError: + pass + else: + object.__delattr__(self, "value") + try: + return v + finally: + del v + raise AlreadyUsedError 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()) @final @@ -202,13 +205,28 @@ class Error(Outcome[NoReturn]): """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()' + + def _unwrap_error(self) -> BaseException: + try: + v = self.error + except AttributeError: + pass + else: + object.__delattr__(self, "error") + try: + return v + finally: + del v + raise AlreadyUsedError 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: @@ -227,12 +245,10 @@ 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()) # A convenience alias to a union of both results, allowing exhaustiveness checking. diff --git a/tests/test_sync.py b/tests/test_sync.py index 855d776..ca33401 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -10,8 +10,9 @@ def test_Outcome(): v = Value(1) assert v.value == 1 - assert v.unwrap() == 1 assert repr(v) == "Value(1)" + assert v.unwrap() == 1 + assert repr(v) == "Value()" with pytest.raises(AlreadyUsedError): v.unwrap() @@ -21,11 +22,12 @@ def test_Outcome(): exc = RuntimeError("oops") e = Error(exc) assert e.error is exc + assert repr(e) == f"Error({exc!r})" with pytest.raises(RuntimeError): e.unwrap() with pytest.raises(AlreadyUsedError): e.unwrap() - assert repr(e) == f"Error({exc!r})" + assert repr(e) == "Error()" e = Error(exc) with pytest.raises(TypeError): From 4d40df04857f0e32214431cc3e7767c3ac39c563 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 22 Mar 2025 11:42:34 +0000 Subject: [PATCH 02/15] yapf --- src/outcome/_impl.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 3c287bd..cc05015 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -122,6 +122,7 @@ class Outcome(abc.ABC, Generic[ValueT]): hashable. """ + @abc.abstractmethod def unwrap(self) -> ValueT: """Return or raise the contained value or exception. From e0f317813a499f1a3629b37c3b8caed72825d9c0 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Sat, 22 Mar 2025 16:48:06 +0000 Subject: [PATCH 03/15] no need to delete v in unwrap/_unwrap_error --- src/outcome/_impl.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index cc05015..796fcf6 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -180,10 +180,7 @@ def unwrap(self) -> ValueT: pass else: object.__delattr__(self, "value") - try: - return v - finally: - del v + return v raise AlreadyUsedError def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT: @@ -218,10 +215,7 @@ def _unwrap_error(self) -> BaseException: pass else: object.__delattr__(self, "error") - try: - return v - finally: - del v + return v raise AlreadyUsedError def unwrap(self) -> NoReturn: From 6f2fe689622ee9d2ce3a09523c0cde744c94e0e7 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 29 Jan 2026 11:04:56 +0000 Subject: [PATCH 04/15] replace with --- src/outcome/_impl.py | 4 ++-- tests/test_sync.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 796fcf6..fc77cb9 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -171,7 +171,7 @@ def __repr__(self) -> str: try: return f'Value({self.value!r})' except AttributeError: - return f'Value()' + return f'Value()' def unwrap(self) -> ValueT: try: @@ -206,7 +206,7 @@ def __repr__(self) -> str: try: return f'Error({self.error!r})' except AttributeError: - return 'Error()' + return 'Error()' def _unwrap_error(self) -> BaseException: try: diff --git a/tests/test_sync.py b/tests/test_sync.py index ca33401..5c4358e 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -12,7 +12,7 @@ def test_Outcome(): assert v.value == 1 assert repr(v) == "Value(1)" assert v.unwrap() == 1 - assert repr(v) == "Value()" + assert repr(v) == "Value()" with pytest.raises(AlreadyUsedError): v.unwrap() From 2606efb73f3d2044ed47c00ce62755c43696d998 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 29 Jan 2026 11:16:48 +0000 Subject: [PATCH 05/15] make error and value private fields accessed by properties add types e.error unwraps --- src/outcome/_impl.py | 24 ++++++++++++++++-------- tests/test_async.py | 5 +++-- tests/test_sync.py | 13 +++++++++---- 3 files changed, 28 insertions(+), 14 deletions(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index fc77cb9..40dd596 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -164,22 +164,22 @@ class Value(Outcome[ValueT], Generic[ValueT]): """ - value: ValueT = attr.ib() + _value: ValueT = attr.ib() """The contained value.""" def __repr__(self) -> str: try: - return f'Value({self.value!r})' + return f'Value({self._value!r})' except AttributeError: return f'Value()' def unwrap(self) -> ValueT: try: - v = self.value + v = self._value except AttributeError: pass else: - object.__delattr__(self, "value") + object.__delattr__(self, "_value") return v raise AlreadyUsedError @@ -189,6 +189,10 @@ def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT: async def asend(self, agen: AsyncGenerator[ResultT, ValueT]) -> ResultT: return await agen.asend(self.unwrap()) + @property + def value(self) -> ValueT: + return self.unwrap() + @final @attr.s(frozen=True, repr=False, slots=True) @@ -197,24 +201,24 @@ 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: try: - return f'Error({self.error!r})' + return f'Error({self._error!r})' except AttributeError: return 'Error()' def _unwrap_error(self) -> BaseException: try: - v = self.error + v = self._error except AttributeError: pass else: - object.__delattr__(self, "error") + object.__delattr__(self, "_error") return v raise AlreadyUsedError @@ -245,6 +249,10 @@ def send(self, gen: Generator[ResultT, NoReturn, object]) -> ResultT: async def asend(self, agen: AsyncGenerator[ResultT, NoReturn]) -> ResultT: return await agen.athrow(self._unwrap_error()) + @property + def error(self) -> BaseException: + return self._unwrap_error() + # A convenience alias to a union of both results, allowing exhaustiveness checking. Maybe = Union[Value[ValueT], Error] diff --git a/tests/test_async.py b/tests/test_async.py index 5ff95fd..58aee3f 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -22,8 +22,9 @@ async def raise_ValueError(x): raise ValueError(x) e = await outcome.acapture(raise_ValueError, 9) - assert type(e.error) is ValueError - assert e.error.args == (9,) + error = e.error + assert type(error) is ValueError + assert error.args == (9,) async def test_asend(): diff --git a/tests/test_sync.py b/tests/test_sync.py index 5c4358e..32c5d00 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -10,6 +10,7 @@ def test_Outcome(): v = Value(1) assert v.value == 1 + v = Value(1) assert repr(v) == "Value(1)" assert v.unwrap() == 1 assert repr(v) == "Value()" @@ -21,13 +22,16 @@ def test_Outcome(): exc = RuntimeError("oops") e = Error(exc) - assert e.error is exc + error = e.error + assert error is exc + e = Error(exc) assert repr(e) == f"Error({exc!r})" + e = Error(exc) with pytest.raises(RuntimeError): e.unwrap() with pytest.raises(AlreadyUsedError): e.unwrap() - assert repr(e) == "Error()" + assert repr(e) == "Error()" e = Error(exc) with pytest.raises(TypeError): @@ -101,8 +105,9 @@ def raise_ValueError(x): e = outcome.capture(raise_ValueError, "two") assert type(e) == Error - assert type(e.error) is ValueError - assert e.error.args == ("two",) + error = e.error + assert type(error) is ValueError + assert error.args == ("two",) def test_inheritance(): From a28ec1aaf57c4faa5df5a862bd89da60a78a8d37 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 29 Jan 2026 12:14:38 +0000 Subject: [PATCH 06/15] Revert "make error and value private fields accessed by properties" This reverts commit 2606efb73f3d2044ed47c00ce62755c43696d998. --- src/outcome/_impl.py | 24 ++++++++---------------- tests/test_async.py | 5 ++--- tests/test_sync.py | 13 ++++--------- 3 files changed, 14 insertions(+), 28 deletions(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 40dd596..fc77cb9 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -164,22 +164,22 @@ class Value(Outcome[ValueT], Generic[ValueT]): """ - _value: ValueT = attr.ib() + value: ValueT = attr.ib() """The contained value.""" def __repr__(self) -> str: try: - return f'Value({self._value!r})' + return f'Value({self.value!r})' except AttributeError: return f'Value()' def unwrap(self) -> ValueT: try: - v = self._value + v = self.value except AttributeError: pass else: - object.__delattr__(self, "_value") + object.__delattr__(self, "value") return v raise AlreadyUsedError @@ -189,10 +189,6 @@ def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT: async def asend(self, agen: AsyncGenerator[ResultT, ValueT]) -> ResultT: return await agen.asend(self.unwrap()) - @property - def value(self) -> ValueT: - return self.unwrap() - @final @attr.s(frozen=True, repr=False, slots=True) @@ -201,24 +197,24 @@ 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: try: - return f'Error({self._error!r})' + return f'Error({self.error!r})' except AttributeError: return 'Error()' def _unwrap_error(self) -> BaseException: try: - v = self._error + v = self.error except AttributeError: pass else: - object.__delattr__(self, "_error") + object.__delattr__(self, "error") return v raise AlreadyUsedError @@ -249,10 +245,6 @@ def send(self, gen: Generator[ResultT, NoReturn, object]) -> ResultT: async def asend(self, agen: AsyncGenerator[ResultT, NoReturn]) -> ResultT: return await agen.athrow(self._unwrap_error()) - @property - def error(self) -> BaseException: - return self._unwrap_error() - # A convenience alias to a union of both results, allowing exhaustiveness checking. Maybe = Union[Value[ValueT], Error] diff --git a/tests/test_async.py b/tests/test_async.py index 58aee3f..5ff95fd 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -22,9 +22,8 @@ async def raise_ValueError(x): raise ValueError(x) e = await outcome.acapture(raise_ValueError, 9) - error = e.error - assert type(error) is ValueError - assert error.args == (9,) + assert type(e.error) is ValueError + assert e.error.args == (9,) async def test_asend(): diff --git a/tests/test_sync.py b/tests/test_sync.py index 32c5d00..5c4358e 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -10,7 +10,6 @@ def test_Outcome(): v = Value(1) assert v.value == 1 - v = Value(1) assert repr(v) == "Value(1)" assert v.unwrap() == 1 assert repr(v) == "Value()" @@ -22,16 +21,13 @@ def test_Outcome(): exc = RuntimeError("oops") e = Error(exc) - error = e.error - assert error is exc - e = Error(exc) + assert e.error is exc assert repr(e) == f"Error({exc!r})" - e = Error(exc) with pytest.raises(RuntimeError): e.unwrap() with pytest.raises(AlreadyUsedError): e.unwrap() - assert repr(e) == "Error()" + assert repr(e) == "Error()" e = Error(exc) with pytest.raises(TypeError): @@ -105,9 +101,8 @@ def raise_ValueError(x): e = outcome.capture(raise_ValueError, "two") assert type(e) == Error - error = e.error - assert type(error) is ValueError - assert error.args == ("two",) + assert type(e.error) is ValueError + assert e.error.args == ("two",) def test_inheritance(): From fdbd48d1a62087b5bbff36cb04c3769c8f3bac2e Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Thu, 29 Jan 2026 12:19:33 +0000 Subject: [PATCH 07/15] make error and value private fields accessed by properties attempt 2 --- src/outcome/_impl.py | 34 +++++++++++++++++++++++++--------- tests/test_sync.py | 2 +- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index fc77cb9..9657272 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -164,22 +164,22 @@ class Value(Outcome[ValueT], Generic[ValueT]): """ - value: ValueT = attr.ib() + _value: ValueT = attr.ib() """The contained value.""" def __repr__(self) -> str: try: - return f'Value({self.value!r})' + return f'Value({self._value!r})' except AttributeError: return f'Value()' def unwrap(self) -> ValueT: try: - v = self.value - except AttributeError: + v = self._value + except AttributeError as e: pass else: - object.__delattr__(self, "value") + object.__delattr__(self, "_value") return v raise AlreadyUsedError @@ -189,6 +189,14 @@ def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT: async def asend(self, agen: AsyncGenerator[ResultT, ValueT]) -> ResultT: return await agen.asend(self.unwrap()) + @property + def value(self) -> ValueT: + try: + return self._value + except AttributeError as e: + pass + raise AlreadyUsedError + @final @attr.s(frozen=True, repr=False, slots=True) @@ -197,24 +205,24 @@ 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: try: - return f'Error({self.error!r})' + return f'Error({self._error!r})' except AttributeError: return 'Error()' def _unwrap_error(self) -> BaseException: try: - v = self.error + v = self._error except AttributeError: pass else: - object.__delattr__(self, "error") + object.__delattr__(self, "_error") return v raise AlreadyUsedError @@ -245,6 +253,14 @@ def send(self, gen: Generator[ResultT, NoReturn, object]) -> ResultT: async def asend(self, agen: AsyncGenerator[ResultT, NoReturn]) -> ResultT: return await agen.athrow(self._unwrap_error()) + @property + def error(self) -> BaseException: + try: + return self._error + except AttributeError: + pass + raise AlreadyUsedError + # A convenience alias to a union of both results, allowing exhaustiveness checking. Maybe = Union[Value[ValueT], Error] diff --git a/tests/test_sync.py b/tests/test_sync.py index 5c4358e..82f5dfe 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -27,7 +27,7 @@ def test_Outcome(): e.unwrap() with pytest.raises(AlreadyUsedError): e.unwrap() - assert repr(e) == "Error()" + assert repr(e) == "Error()" e = Error(exc) with pytest.raises(TypeError): From c73c831c8779a0fbf91710ce91591824bd2a8f36 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 30 Jan 2026 07:26:02 +0000 Subject: [PATCH 08/15] Update src/outcome/_impl.py --- src/outcome/_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 9657272..06935a0 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -171,7 +171,7 @@ def __repr__(self) -> str: try: return f'Value({self._value!r})' except AttributeError: - return f'Value()' + return 'Value()' def unwrap(self) -> ValueT: try: From bb7e10706a4530886c9cecd0859fe35a658b5e79 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 30 Jan 2026 08:42:23 +0000 Subject: [PATCH 09/15] add .peek() --- src/outcome/_impl.py | 39 ++++++++++++++++++++++++++++++++++++++- tests/test_async.py | 5 +++++ tests/test_sync.py | 8 ++++++++ 3 files changed, 51 insertions(+), 1 deletion(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 06935a0..c90c5fd 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -122,10 +122,23 @@ class Outcome(abc.ABC, Generic[ValueT]): hashable. """ + @abc.abstractmethod + def peek(self) -> ValueT: + """Return or raise the contained value or exception, without + invalidating the outcome. + + These two lines of code are equivalent:: + + x = fn(*args) + x = outcome.capture(fn, *args).peek() + + """ + @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:: @@ -173,6 +186,9 @@ def __repr__(self) -> str: except AttributeError: return 'Value()' + def peek(self) -> ValueT: + return self.value + def unwrap(self) -> ValueT: try: v = self._value @@ -226,6 +242,27 @@ def _unwrap_error(self) -> BaseException: return v raise AlreadyUsedError + 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 = 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 'unwrap' + # methods frame, we avoid the 'captured_error' object's + # __traceback__ from indirectly referencing 'captured_error'. + del captured_error, self + def unwrap(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. diff --git a/tests/test_async.py b/tests/test_async.py index 5ff95fd..9989832 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -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) @@ -24,6 +25,10 @@ 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): + e.peek() + with pytest.raises(ValueError): + e.unwrap() async def test_asend(): diff --git a/tests/test_sync.py b/tests/test_sync.py index 82f5dfe..ca0f151 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -11,6 +11,7 @@ def test_Outcome(): v = Value(1) assert v.value == 1 assert repr(v) == "Value(1)" + assert v.peek() == 1 assert v.unwrap() == 1 assert repr(v) == "Value()" @@ -23,6 +24,8 @@ def test_Outcome(): e = Error(exc) assert e.error is exc assert repr(e) == f"Error({exc!r})" + with pytest.raises(RuntimeError): + e.peek() with pytest.raises(RuntimeError): e.unwrap() with pytest.raises(AlreadyUsedError): @@ -94,6 +97,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): @@ -103,6 +107,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(): From b30cc2bee9488293eb4c10a5703e5eb8db9da577 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 30 Jan 2026 08:45:06 +0000 Subject: [PATCH 10/15] add newsfragment --- newsfragments/47.feature.rst | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 newsfragments/47.feature.rst diff --git a/newsfragments/47.feature.rst b/newsfragments/47.feature.rst new file mode 100644 index 0000000..807a164 --- /dev/null +++ b/newsfragments/47.feature.rst @@ -0,0 +1,3 @@ +Remove reference to value/error when unwrapping outcome. +Provide a ``.peek()`` method to recieve wrapped +value/error without invalidating the outcome. From f863df438ac98a46084255c83dca31dc5009a302 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 30 Jan 2026 08:48:05 +0000 Subject: [PATCH 11/15] Apply suggestion from @graingert --- src/outcome/_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index c90c5fd..0197f2d 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -258,7 +258,7 @@ def peek(self) -> NoReturn: # # https://github.com/python-trio/trio/issues/1770 # - # In particuar, by deleting this local variables from the 'unwrap' + # 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 From 28fecf48381ccf2816aa7f5a57d509d715331166 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Fri, 30 Jan 2026 08:56:00 +0000 Subject: [PATCH 12/15] yapf --- src/outcome/_impl.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 0197f2d..3b94d4a 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -122,6 +122,7 @@ class Outcome(abc.ABC, Generic[ValueT]): hashable. """ + @abc.abstractmethod def peek(self) -> ValueT: """Return or raise the contained value or exception, without @@ -134,7 +135,6 @@ def peek(self) -> ValueT: """ - @abc.abstractmethod def unwrap(self) -> ValueT: """Return or raise the contained value or exception, and invalidate From 4322626bfc00ee281f36c37996f6772667d87bdd Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Tue, 3 Feb 2026 08:13:14 +0000 Subject: [PATCH 13/15] Error.peek() raises a copy of the wrapped error --- newsfragments/47.feature.rst | 3 ++- src/outcome/_impl.py | 9 +++++---- tests/test_async.py | 6 ++++-- tests/test_sync.py | 7 +++++-- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/newsfragments/47.feature.rst b/newsfragments/47.feature.rst index 807a164..7fd1c92 100644 --- a/newsfragments/47.feature.rst +++ b/newsfragments/47.feature.rst @@ -1,3 +1,4 @@ Remove reference to value/error when unwrapping outcome. Provide a ``.peek()`` method to recieve wrapped -value/error without invalidating the outcome. +value or a copy of the wrapped error +without invalidating the outcome. diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 3b94d4a..024e276 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc +import copy from typing import ( TYPE_CHECKING, AsyncGenerator, @@ -125,10 +126,10 @@ class Outcome(abc.ABC, Generic[ValueT]): @abc.abstractmethod def peek(self) -> ValueT: - """Return or raise the contained value or exception, without - invalidating the outcome. + """Return the contained value or raise a copy of the contained + exception, without invalidating the outcome. - These two lines of code are equivalent:: + These two lines of code are almost equivalent:: x = fn(*args) x = outcome.capture(fn, *args).peek() @@ -245,7 +246,7 @@ def _unwrap_error(self) -> BaseException: 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 = self.error + captured_error = copy.copy(self.error) try: raise captured_error finally: diff --git a/tests/test_async.py b/tests/test_async.py index 9989832..f838635 100644 --- a/tests/test_async.py +++ b/tests/test_async.py @@ -25,11 +25,13 @@ 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): + with pytest.raises(ValueError) as exc_info: e.peek() - with pytest.raises(ValueError): + with pytest.raises(ValueError) as exc_info2: e.unwrap() + assert exc_info.value is not exc_info2.value + async def test_asend(): async def my_agen_func(): diff --git a/tests/test_sync.py b/tests/test_sync.py index ca0f151..3f086eb 100644 --- a/tests/test_sync.py +++ b/tests/test_sync.py @@ -24,10 +24,13 @@ def test_Outcome(): e = Error(exc) assert e.error is exc assert repr(e) == f"Error({exc!r})" - with pytest.raises(RuntimeError): + with pytest.raises(RuntimeError) as exc_info: e.peek() - with pytest.raises(RuntimeError): + 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) == "Error()" From 18a2d77a86ecd9b7d8bd3f3668ba748216a803b9 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 4 Feb 2026 08:15:50 +0000 Subject: [PATCH 14/15] move attr docstrings to properties --- src/outcome/_impl.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 024e276..484c09f 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -179,7 +179,6 @@ class Value(Outcome[ValueT], Generic[ValueT]): """ _value: ValueT = attr.ib() - """The contained value.""" def __repr__(self) -> str: try: @@ -208,6 +207,7 @@ async def asend(self, agen: AsyncGenerator[ResultT, ValueT]) -> ResultT: @property def value(self) -> ValueT: + """The contained value.""" try: return self._value except AttributeError as e: @@ -225,7 +225,6 @@ class Error(Outcome[NoReturn]): _error: BaseException = attr.ib( validator=attr.validators.instance_of(BaseException) ) - """The contained exception object.""" def __repr__(self) -> str: try: @@ -293,6 +292,7 @@ async def asend(self, agen: AsyncGenerator[ResultT, NoReturn]) -> ResultT: @property def error(self) -> BaseException: + """The contained exception object.""" try: return self._error except AttributeError: From 9fbf91492451c7eb911375fbdadccf962082ff59 Mon Sep 17 00:00:00 2001 From: Thomas Grainger Date: Wed, 4 Feb 2026 08:19:38 +0000 Subject: [PATCH 15/15] dedupe AlreadyUsedError code, using the properties --- src/outcome/_impl.py | 22 ++++++---------------- 1 file changed, 6 insertions(+), 16 deletions(-) diff --git a/src/outcome/_impl.py b/src/outcome/_impl.py index 484c09f..dc84ca8 100644 --- a/src/outcome/_impl.py +++ b/src/outcome/_impl.py @@ -190,14 +190,9 @@ def peek(self) -> ValueT: return self.value def unwrap(self) -> ValueT: - try: - v = self._value - except AttributeError as e: - pass - else: - object.__delattr__(self, "_value") - return v - raise AlreadyUsedError + v = self.value + object.__delattr__(self, "_value") + return v def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT: return gen.send(self.unwrap()) @@ -233,14 +228,9 @@ def __repr__(self) -> str: return 'Error()' def _unwrap_error(self) -> BaseException: - try: - v = self._error - except AttributeError: - pass - else: - object.__delattr__(self, "_error") - return v - raise AlreadyUsedError + 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