Skip to content

Commit b5f58f2

Browse files
committed
add .unwrap_and_destroy() method
1 parent 03ed621 commit b5f58f2

3 files changed

Lines changed: 72 additions & 3 deletions

File tree

newsfragments/47.feature.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
Add ``unwrap_and_destroy`` method to remove references to
2+
the wrapped exception or value to prevent issues where
3+
values not being garbage collected when they are no longer
4+
needed, or worse problems with exceptions leaving a
5+
reference cycle.

src/outcome/_impl.py

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,23 @@ def unwrap(self) -> ValueT:
138138
x = fn(*args)
139139
x = outcome.capture(fn, *args).unwrap()
140140
141+
Note: this leaves a reference to the contained value or exception
142+
alive which may result in values not being garbage collected or
143+
exceptions leaving a reference cycle. If this is an issue it's
144+
recommended to call the ``unwrap_and_destroy()`` method
145+
146+
"""
147+
148+
@abc.abstractmethod
149+
def unwrap_and_destroy(self) -> ValueT:
150+
"""Return or raise the contained value or exception, remove the
151+
reference to the contained value or exception.
152+
153+
These two lines of code are equivalent::
154+
155+
x = fn(*args)
156+
x = outcome.capture(fn, *args).unwrap_and_destroy()
157+
141158
"""
142159

143160
@abc.abstractmethod
@@ -174,12 +191,21 @@ class Value(Outcome[ValueT], Generic[ValueT]):
174191
"""The contained value."""
175192

176193
def __repr__(self) -> str:
177-
return f'Value({self.value!r})'
194+
try:
195+
return f'Value({self.value!r})'
196+
except AttributeError:
197+
return f'Value(<AlreadyDestroyed>)'
178198

179199
def unwrap(self) -> ValueT:
180200
self._set_unwrapped()
181201
return self.value
182202

203+
def unwrap_and_destroy(self):
204+
self._set_unwrapped()
205+
v = self.value
206+
object.__delattr__(self, "value")
207+
return v
208+
183209
def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT:
184210
self._set_unwrapped()
185211
return gen.send(self.value)
@@ -202,7 +228,10 @@ class Error(Outcome[NoReturn]):
202228
"""The contained exception object."""
203229

204230
def __repr__(self) -> str:
205-
return f'Error({self.error!r})'
231+
try:
232+
return f'Error({self.error!r})'
233+
except AttributeError:
234+
return f'Error(<AlreadyDestroyed>)'
206235

207236
def unwrap(self) -> NoReturn:
208237
self._set_unwrapped()
@@ -226,6 +255,29 @@ def unwrap(self) -> NoReturn:
226255
# __traceback__ from indirectly referencing 'captured_error'.
227256
del captured_error, self
228257

258+
def unwrap_and_destroy(self) -> NoReturn:
259+
self._set_unwrapped()
260+
# Tracebacks show the 'raise' line below out of context, so let's give
261+
# this variable a name that makes sense out of context.
262+
captured_error = self.error
263+
object.__delattr__(self, "error")
264+
try:
265+
raise captured_error
266+
finally:
267+
# We want to avoid creating a reference cycle here. Python does
268+
# collect cycles just fine, so it wouldn't be the end of the world
269+
# if we did create a cycle, but the cyclic garbage collector adds
270+
# latency to Python programs, and the more cycles you create, the
271+
# more often it runs, so it's nicer to avoid creating them in the
272+
# first place. For more details see:
273+
#
274+
# https://github.com/python-trio/trio/issues/1770
275+
#
276+
# In particuar, by deleting this local variables from the 'unwrap'
277+
# methods frame, we avoid the 'captured_error' object's
278+
# __traceback__ from indirectly referencing 'captured_error'.
279+
del captured_error, self
280+
229281
def send(self, gen: Generator[ResultT, NoReturn, object]) -> ResultT:
230282
self._set_unwrapped()
231283
return gen.throw(self.error)

tests/test_sync.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ def test_Outcome():
1616
with pytest.raises(AlreadyUsedError):
1717
v.unwrap()
1818

19-
v = Value(1)
19+
v = Value(2)
20+
assert v.unwrap_and_destroy() == 2
21+
assert repr(v) == "Value(<AlreadyDestroyed>)"
22+
with pytest.raises(AlreadyUsedError):
23+
v.unwrap_and_destroy()
2024

2125
exc = RuntimeError("oops")
2226
e = Error(exc)
@@ -33,12 +37,20 @@ def test_Outcome():
3337
with pytest.raises(TypeError):
3438
Error(RuntimeError)
3539

40+
e2 = Error(exc)
41+
with pytest.raises(RuntimeError):
42+
e2.unwrap_and_destroy()
43+
with pytest.raises(AlreadyUsedError):
44+
e2.unwrap_and_destroy()
45+
assert repr(e2) == "Error(<AlreadyDestroyed>)"
46+
3647
def expect_1():
3748
assert (yield) == 1
3849
yield "ok"
3950

4051
it = iter(expect_1())
4152
next(it)
53+
v = Value(1)
4254
assert v.send(it) == "ok"
4355
with pytest.raises(AlreadyUsedError):
4456
v.send(it)

0 commit comments

Comments
 (0)