Skip to content

Commit e978344

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

File tree

3 files changed

+75
-3
lines changed

3 files changed

+75
-3
lines changed

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: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,8 +138,26 @@ 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

160+
143161
@abc.abstractmethod
144162
def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT:
145163
"""Send or throw the contained value or exception into the given
@@ -174,12 +192,22 @@ class Value(Outcome[ValueT], Generic[ValueT]):
174192
"""The contained value."""
175193

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

179200
def unwrap(self) -> ValueT:
180201
self._set_unwrapped()
181202
return self.value
182203

204+
def unwrap_and_destroy(self):
205+
self._set_unwrapped()
206+
v = self.value
207+
object.__delattr__(self, "value")
208+
return v
209+
210+
183211
def send(self, gen: Generator[ResultT, ValueT, object]) -> ResultT:
184212
self._set_unwrapped()
185213
return gen.send(self.value)
@@ -202,7 +230,10 @@ class Error(Outcome[NoReturn]):
202230
"""The contained exception object."""
203231

204232
def __repr__(self) -> str:
205-
return f'Error({self.error!r})'
233+
try:
234+
return f'Error({self.error!r})'
235+
except AttributeError:
236+
return f'Error(<AlreadyDestroyed>)'
206237

207238
def unwrap(self) -> NoReturn:
208239
self._set_unwrapped()
@@ -226,6 +257,29 @@ def unwrap(self) -> NoReturn:
226257
# __traceback__ from indirectly referencing 'captured_error'.
227258
del captured_error, self
228259

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

tests/test_sync.py

Lines changed: 14 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,21 @@ def test_Outcome():
3337
with pytest.raises(TypeError):
3438
Error(RuntimeError)
3539

40+
41+
e2 = Error(exc)
42+
with pytest.raises(RuntimeError):
43+
e2.unwrap_and_destroy()
44+
with pytest.raises(AlreadyUsedError):
45+
e2.unwrap_and_destroy()
46+
assert repr(e2) == "Error(<AlreadyDestroyed>)"
47+
3648
def expect_1():
3749
assert (yield) == 1
3850
yield "ok"
3951

4052
it = iter(expect_1())
4153
next(it)
54+
v = Value(1)
4255
assert v.send(it) == "ok"
4356
with pytest.raises(AlreadyUsedError):
4457
v.send(it)

0 commit comments

Comments
 (0)