8000 Merge pull request #10891 from eric-wieser/assert-no-cycles · numpy/numpy@b5c1bcf · GitHub
[go: up one dir, main page]

Skip to content

Commit b5c1bcf

Browse files
authored
Merge pull request #10891 from eric-wieser/assert-no-cycles
TST: Extract a helper function to test for reference cycles
2 parents e0b5e87 + 3ff0c5c commit b5c1bcf

File tree

4 files changed

+167
-14
lines changed

4 files changed

+167
-14
lines changed

numpy/lib/tests/test_io.py

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from numpy.testing import (
2424
assert_warns, assert_, SkipTest, assert_raises_regex, assert_raises,
2525
assert_allclose, assert_array_equal, temppath, tempdir, IS_PYPY,
26-
HAS_REFCOUNT, suppress_warnings,
26+
HAS_REFCOUNT, suppress_warnings, assert_no_gc_cycles,
2727
)
2828

2929

@@ -2416,14 +2416,5 @@ def test_load_refcount():
24162416
np.savez(f, [1, 2, 3])
24172417
f.seek(0)
24182418

2419-
assert_(gc.isenabled())
2420-
gc.disable()
2421-
try:
2422-
gc.collect()
2419+
with assert_no_gc_cycles():
24232420
np.load(f)
2424-
# gc.collect returns the number of unreachable objects in cycles that
2425-
# were found -- we are checking that no cycles were created by np.load
2426-
n_objects_in_cycles = gc.collect()
2427-
finally:
2428-
gc.enable()
2429-
assert_equal(n_objects_in_cycles, 0)

numpy/testing/_private/utils.py

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77
import os
88
import sys
99
import re
10+
import gc
1011
import operator
1112
import warnings
1213
from functools import partial, wraps
1314
import shutil
1415
import contextlib
1516
from tempfile import mkdtemp, mkstemp
1617
from unittest.case import SkipTest
18+
import pprint
1719

1820
from numpy.core import(
1921
float32, empty, arange, array_repr, ndarray, isnat, array)
@@ -35,7 +37,7 @@
3537
'assert_allclose', 'IgnoreException', 'clear_and_catch_warnings',
3638
'SkipTest', 'KnownFailureException', 'temppath', 'tempdir', 'IS_PYPY',
3739
'HAS_REFCOUNT', 'suppress_warnings', 'assert_array_compare',
38-
'_assert_valid_refcount', '_gen_alignment_data',
40+
'_assert_valid_refcount', '_gen_alignment_data', 'assert_no_gc_cycles',
3941
]
4042

4143

@@ -2272,3 +2274,89 @@ def new_func(*args, **kwargs):
22722274
return func(*args, **kwargs)
22732275

22742276
return new_func
2277+
2278+
2279+
@contextlib.contextmanager
2280+
def _assert_no_gc_cycles_context(name=None):
2281+
__tracebackhide__ = True # Hide traceback for py.test
2282+
2283+
# not meaningful to test if there is no refcounting
2284+
if not HAS_REFCOUNT:
2285+
return
2286+
2287+
assert_(gc.isenabled())
2288+
gc.disable()
2289+
gc_debug = gc.get_debug()
2290+
try:
2291+
for i in range(100):
2292+
if gc.collect() == 0:
2293+
break
2294+
else:
2295+
raise RuntimeError(
2296+
"Unable to fully collect garbage - perhaps a __del__ method is "
2297+
"creating more reference cycles?")
2298+
2299+
gc.set_debug(gc.DEBUG_SAVEALL)
2300+
yield
2301+
# gc.collect returns the number of unreachable objects in cycles that
2302+
# were found -- we are checking that no cycles were created in the context
2303+
n_objects_in_cycles = gc.collect()
2304+
objects_in_cycles = gc.garbage[:]
2305+
finally:
2306+
del gc.garbage[:]
2307+
gc.set_debug(gc_debug)
2308+
gc.enable()
2309+
2310+
if n_objects_in_cycles:
2311+
name_str = " when calling %s" % name if name is not None else ""
2312+
raise AssertionError(
2313+
"Reference cycles were found{}: {} objects were collected, "
2314+
"of which {} are shown below:{}"
2315+
.format(
2316+
name_str,
2317+
n_objects_in_cycles,
2318+
len(objects_in_cycles),
2319+
''.join(
2320+
"\n {} object with id={}:\n {}".format(
2321+
type(o).__name__,
2322+
id(o),
2323+
pprint.pformat(o).replace('\n', '\n ')
2324+
) for o in objects_in_cycles
2325+
)
2326+
)
2327+
)
2328+
2329+
2330+
def assert_no_gc_cycles(*args, **kwargs):
2331+
"""
2332+
Fail if the given callable produces any reference cycles.
2333+
2334+
If called with all arguments omitted, may be used as a context manager:
2335+
2336+
with assert_no_gc_cycles():
2337+
do_something()
2338+
2339+
.. versionadded:: 1.15.0
2340+
2341+
Parameters
2342+
----------
2343+
func : callable
2344+
The callable to test.
2345+
\\*args : Arguments
2346+
Arguments passed to `func`.
2347+
\\*\\*kwargs : Kwargs
2348+
Keyword arguments passed to `func`.
2349+
2350+
Returns
2351+
-------
2352+
Nothing. The result is deliberately discarded to ensure that all cycles
2353+
are found.
2354+
2355+
"""
2356+
if not args:
2357+
return _assert_no_gc_cycles_context()
2358+
2359+
func = args[0]
2360+
args = args[1:]
2361+
with _assert_no_gc_cycles_context(name=func.__name__):
2362+
func(*args, **kwargs)

numpy/testing/tests/test_utils.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import itertools
77
import textwrap
88
import pytest
9+
import weakref
910

1011
import numpy as np
1112
from numpy.testing import (
@@ -14,7 +15,7 @@
1415
assert_raises, assert_warns, assert_no_warnings, assert_allclose,
1516
assert_approx_equal, assert_array_almost_equal_nulp, assert_array_max_ulp,
1617
clear_and_catch_warnings, suppress_warnings, assert_string_equal, assert_,
17-
tempdir, temppath,
18+
tempdir, temppath, assert_no_gc_cycles, HAS_REFCOUNT
1819
)
1920

2021

@@ -1360,3 +1361,76 @@ def test_clear_and_catch_warnings_inherit():
13601361
warnings.simplefilter('ignore')
13611362
warnings.warn('Some warning')
13621363
assert_equal(my_mod.__warningregistry__, {})
1364+
1365+
1366+
@pytest.mark.skipif(not HAS_REFCOUNT, reason="Python lacks refcounts")
1367+
class TestAssertNoGcCycles(object):
1368+
""" Test assert_no_gc_cycles """
1369+
def test_passes(self):
1370+
def no_cycle():
1371+
b = []
1372+
b.append([])
1373+
return b
1374+
1375+
with assert_no_gc_cycles():
1376+
no_cycle()
1377+
1378+
assert_no_gc_cycles(no_cycle)
1379+
1380+
1381+
def test_asserts(self):
1382+
def make_cycle():
1383+
a = []
1384+
a.append(a)
1385+
a.append(a)
1386+
return a
1387+
1388+
with assert_raises(AssertionError):
1389+
with assert_no_gc_cycles():
1390+
make_cycle()
1391+
1392+
with assert_raises(AssertionError):
1393+
assert_no_gc_cycles(make_cycle)
1394+
1395+
1396+
def test_fails(self):
1397+
"""
1398+
Test that in cases where the garbage cannot be collected, we raise an
1399+
error, instead of hanging forever trying to clear it.
1400+
"""
1401+
1402+
class ReferenceCycleInDel(object):
1403+
"""
1404+
An object that not only contains a reference cycle, but creates new
1405+
cycles whenever it's garbage-collected and its __del__ runs
1406+
"""
1407+
make_cycle = True
1408+
1409+
def __init__(self):
1410+
self.cycle = self
1411+
1412+
def __del__(self):
1413+
# break the current cycle so that `self` can be freed
1414+
self.cycle = None
1415+
1416+
if ReferenceCycleInDel.make_cycle:
1417+
# but create a new one so that the garbage collector has more
1418+
# work to do.
1419+
ReferenceCycleInDel()
1420+
1421+
try:
1422+
w = weakref.ref(ReferenceCycleInDel())
1423+
try:
1424+
with assert_raises(RuntimeError):
1425+
# this will be unable to get a baseline empty garbage
1426+
assert_no_gc_cycles(lambda: None)
1427+
except AssertionError:
1428+
# the above test is only necessary if the GC actually tried to free
1429+
# our object anyway, which python 2.7 does not.
1430+
if w() is not None:
1431+
pytest.skip("GC does not call __del__ on cyclic objects")
1432+
raise
1433+
1434+
finally:
1435+
# make sure that we stop creating reference cycles
1436+
ReferenceCycleInDel.make_cycle = False

numpy/testing/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,5 @@
2525
'assert_allclose', 'IgnoreException', 'clear_and_catch_warnings',
2626
'SkipTest', 'KnownFailureException', 'temppath', 'tempdir', 'IS_PYPY',
2727
'HAS_REFCOUNT', 'suppress_warnings', 'assert_array_compare',
28-
'_assert_valid_refcount', '_gen_alignment_data',
28+
'_assert_valid_refcount', '_gen_alignment_data', 'assert_no_gc_cycles'
2929
]

0 commit comments

Comments
 (0)
0