Skip to content

Commit 41b420a

Browse files
authored
Merge pull request #3509 from HypothesisWorks/register-random
Raise if register_random is passed unreferenced object
2 parents 92fafe3 + 32ba8f9 commit 41b420a

File tree

7 files changed

+221
-17
lines changed

7 files changed

+221
-17
lines changed

hypothesis-python/RELEASE.rst

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
RELEASE_TYPE: minor
2+
3+
:func:`~hypothesis.register_random` has used :mod:`weakref` since :ref:`v6.27.1`,
4+
allowing the :class:`~random.Random`-compatible objects to be garbage-collected when
5+
there are no other references remaining in order to avoid memory leaks.
6+
We now raise an error or emit a warning when this seems likely to happen immediately.
7+
8+
The type annotation of :func:`~hypothesis.register_random` was also widened so that
9+
structural subtypes of :class:`~random.Random` are accepted by static typecheckers.

hypothesis-python/docs/changes.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1181,7 +1181,7 @@ This patch makes the :command:`hypothesis codemod`
11811181
-------------------
11821182

11831183
This patch changes the backing datastructures of :func:`~hypothesis.register_random`
1184-
and a few internal caches to use :class:`weakref.WeakKeyDictionary`. This reduces
1184+
and a few internal caches to use :class:`weakref.WeakValueDictionary`. This reduces
11851185
memory usage and may improve performance when registered :class:`~random.Random`
11861186
instances are only used for a subset of your tests (:issue:`3131`).
11871187

hypothesis-python/src/hypothesis/internal/entropy.py

Lines changed: 97 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,33 @@
99
# obtain one at https://mozilla.org/MPL/2.0/.
1010

1111
import contextlib
12+
import gc
1213
import random
1314
import sys
15+
import warnings
1416
from itertools import count
15-
from typing import Callable, Hashable, Tuple
17+
from typing import TYPE_CHECKING, Any, Callable, Hashable, Tuple
1618
from weakref import WeakValueDictionary
1719

1820
import hypothesis.core
19-
from hypothesis.errors import InvalidArgument
21+
from hypothesis.errors import HypothesisWarning, InvalidArgument
22+
from hypothesis.internal.compat import PYPY
23+
24+
if TYPE_CHECKING:
25+
if sys.version_info >= (3, 8): # pragma: no cover
26+
from typing import Protocol
27+
else:
28+
from typing_extensions import Protocol
29+
30+
# we can't use this at runtime until from_type supports
31+
# protocols -- breaks ghostwriter tests
32+
class RandomLike(Protocol):
33+
seed: Callable[..., Any]
34+
getstate: Callable[[], Any]
35+
setstate: Callable[..., Any]
36+
37+
else: # pragma: no cover
38+
RandomLike = random.Random
2039

2140
# This is effectively a WeakSet, which allows us to associate the saved states
2241
# with their respective Random instances even as new ones are registered and old
@@ -40,23 +59,89 @@ def __init__(self):
4059
NP_RANDOM = None
4160

4261

43-
def register_random(r: random.Random) -> None:
44-
"""Register the given Random instance for management by Hypothesis.
62+
if not PYPY:
63+
64+
def _get_platform_base_refcount(r: Any) -> int:
65+
return sys.getrefcount(r)
66+
67+
# Determine the number of refcounts created by function scope for
68+
# the given platform / version of Python.
69+
_PLATFORM_REF_COUNT = _get_platform_base_refcount(object())
70+
else: # pragma: no cover
71+
# PYPY doesn't have `sys.getrefcount`
72+
_PLATFORM_REF_COUNT = -1
4573

46-
You can pass ``random.Random`` instances (or other objects with seed,
47-
getstate, and setstate methods) to ``register_random(r)`` to have their
48-
states seeded and restored in the same way as the global PRNGs from the
49-
``random`` and ``numpy.random`` modules.
74+
75+
def register_random(r: RandomLike) -> None:
76+
"""Register (a weakref to) the given Random-like instance for management by
77+
Hypothesis.
78+
79+
You can pass instances of structural subtypes of ``random.Random``
80+
(i.e., objects with seed, getstate, and setstate methods) to
81+
``register_random(r)`` to have their states seeded and restored in the same
82+
way as the global PRNGs from the ``random`` and ``numpy.random`` modules.
5083
5184
All global PRNGs, from e.g. simulation or scheduling frameworks, should
52-
be registered to prevent flaky tests. Hypothesis will ensure that the
53-
PRNG state is consistent for all test runs, or reproducibly varied if you
85+
be registered to prevent flaky tests. Hypothesis will ensure that the
86+
PRNG state is consistent for all test runs, always seeding them to zero and
87+
restoring the previous state after the test, or, reproducibly varied if you
5488
choose to use the :func:`~hypothesis.strategies.random_module` strategy.
89+
90+
``register_random`` only makes `weakrefs
91+
<https://docs.python.org/3/library/weakref.html#module-weakref>`_ to ``r``,
92+
thus ``r`` will only be managed by Hypothesis as long as it has active
93+
references elsewhere at runtime. The pattern ``register_random(MyRandom())``
94+
will raise a ``ReferenceError`` to help protect users from this issue.
95+
This check does not occur for the PyPy interpreter. See the following example for
96+
an illustration of this issue
97+
98+
.. code-block:: python
99+
100+
101+
def my_BROKEN_hook():
102+
r = MyRandomLike()
103+
104+
# `r` will be garbage collected after the hook resolved
105+
# and Hypothesis will 'forget' that it was registered
106+
register_random(r) # Hypothesis will emit a warning
107+
108+
109+
rng = MyRandomLike()
110+
111+
112+
def my_WORKING_hook():
113+
register_random(rng)
55114
"""
56115
if not (hasattr(r, "seed") and hasattr(r, "getstate") and hasattr(r, "setstate")):
57116
raise InvalidArgument(f"r={r!r} does not have all the required methods")
58-
if r not in RANDOMS_TO_MANAGE.values():
59-
RANDOMS_TO_MANAGE[next(_RKEY)] = r
117+
118+
if r in RANDOMS_TO_MANAGE.values():
119+
return
120+
121+
if not PYPY: # pragma: no branch
122+
# PYPY does not have `sys.getrefcount`
123+
gc.collect()
124+
if not gc.get_referrers(r):
125+
if sys.getrefcount(r) <= _PLATFORM_REF_COUNT:
126+
raise ReferenceError(
127+
f"`register_random` was passed `r={r}` which will be "
128+
"garbage collected immediately after `register_random` creates a "
129+
"weakref to it. This will prevent Hypothesis from managing this "
130+
"source of RNG. See the docs for `register_random` for more "
131+
"details."
132+
)
133+
else:
134+
warnings.warn(
135+
HypothesisWarning(
136+
"It looks like `register_random` was passed an object "
137+
"that could be garbage collected immediately after "
138+
"`register_random` creates a weakref to it. This will "
139+
"prevent Hypothesis from managing this source of RNG. "
140+
"See the docs for `register_random` for more details."
141+
)
142+
)
143+
144+
RANDOMS_TO_MANAGE[next(_RKEY)] = r
60145

61146

62147
def get_seeder_and_restorer(

hypothesis-python/src/hypothesis/strategies/_internal/core.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from inspect import Parameter, Signature, isabstract, isclass, signature
2323
from types import FunctionType
2424
from typing import (
25+
TYPE_CHECKING,
2526
Any,
2627
AnyStr,
2728
Callable,
@@ -124,10 +125,12 @@
124125
EllipsisType = type(Ellipsis)
125126

126127

127-
try:
128+
if sys.version_info >= (3, 8): # pragma: no cover
128129
from typing import Protocol
129-
except ImportError: # < py3.8
130-
Protocol = object # type: ignore[assignment]
130+
elif TYPE_CHECKING:
131+
from typing_extensions import Protocol
132+
else: # pragma: no cover
133+
Protocol = object
131134

132135

133136
@cacheable

hypothesis-python/tests/cover/test_random_module.py

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import pytest
1515

1616
from hypothesis import core, find, given, register_random, strategies as st
17-
from hypothesis.errors import InvalidArgument
17+
from hypothesis.errors import HypothesisWarning, InvalidArgument
1818
from hypothesis.internal import entropy
1919
from hypothesis.internal.compat import PYPY
2020
from hypothesis.internal.entropy import deterministic_PRNG
@@ -54,6 +54,9 @@ def test_cannot_register_non_Random():
5454
register_random("not a Random instance")
5555

5656

57+
@pytest.mark.filterwarnings(
58+
"ignore:It looks like `register_random` was passed an object that could be garbage collected"
59+
)
5760
def test_registering_a_Random_is_idempotent():
5861
gc_on_pypy()
5962
n_registered = len(entropy.RANDOMS_TO_MANAGE)
@@ -144,6 +147,9 @@ def test_find_does_not_pollute_state():
144147
assert state_a2 != state_b2
145148

146149

150+
@pytest.mark.filterwarnings(
151+
"ignore:It looks like `register_random` was passed an object that could be garbage collected"
152+
)
147153
def test_evil_prng_registration_nonsense():
148154
gc_on_pypy()
149155
n_registered = len(entropy.RANDOMS_TO_MANAGE)
@@ -172,3 +178,57 @@ def test_evil_prng_registration_nonsense():
172178
# Implicit check, no exception was raised in __exit__
173179
assert r2.getstate() == s2, "reset previously registered random state"
174180
assert r3.getstate() == s4, "retained state when registered within the context"
181+
182+
183+
@pytest.mark.skipif(
184+
PYPY, reason="We can't guard against bad no-reference patterns in pypy."
185+
)
186+
def test_passing_unreferenced_instance_raises():
187+
with pytest.raises(ReferenceError):
188+
register_random(random.Random(0))
189+
190+
191+
@pytest.mark.skipif(
192+
PYPY, reason="We can't guard against bad no-reference patterns in pypy."
193+
)
194+
def test_passing_unreferenced_instance_within_function_scope_raises():
195+
def f():
196+
register_random(random.Random(0))
197+
198+
with pytest.raises(ReferenceError):
199+
f()
200+
201+
202+
@pytest.mark.skipif(
203+
PYPY, reason="We can't guard against bad no-reference patterns in pypy."
204+
)
205+
def test_passing_referenced_instance_within_function_scope_warns():
206+
def f():
207+
r = random.Random(0)
208+
register_random(r)
209+
210+
with pytest.warns(
211+
HypothesisWarning,
212+
match="It looks like `register_random` was passed an object that could be"
213+
" garbage collected",
214+
):
215+
f()
216+
217+
218+
@pytest.mark.filterwarnings(
219+
"ignore:It looks like `register_random` was passed an object that could be garbage collected"
220+
)
221+
@pytest.mark.skipif(
222+
PYPY, reason="We can't guard against bad no-reference patterns in pypy."
223+
)
224+
def test_register_random_within_nested_function_scope():
225+
n_registered = len(entropy.RANDOMS_TO_MANAGE)
226+
227+
def f():
228+
r = random.Random()
229+
register_random(r)
230+
assert len(entropy.RANDOMS_TO_MANAGE) == n_registered + 1
231+
232+
f()
233+
gc_on_pypy()
234+
assert len(entropy.RANDOMS_TO_MANAGE) == n_registered

whole-repo-tests/test_mypy.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,26 @@ def test_bar(x):
540540
assert_mypy_errors(
541541
str(f.realpath()), [(5, "call-overload")], python_version=python_version
542542
)
543+
544+
545+
def test_register_random_interface(tmpdir):
546+
f = tmpdir.join("check_mypy_on_pos_arg_only_strats.py")
547+
f.write(
548+
textwrap.dedent(
549+
"""
550+
from random import Random
551+
from hypothesis import register_random
552+
553+
class MyRandom:
554+
def __init__(self) -> None:
555+
r = Random()
556+
self.seed = r.seed
557+
self.setstate = r.setstate
558+
self.getstate = r.getstate
559+
560+
register_random(MyRandom())
561+
register_random(None) # type: ignore[arg-type]
562+
"""
563+
)
564+
)
565+
assert_mypy_errors(str(f.realpath()), [])

whole-repo-tests/test_pyright.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,30 @@ def test_pyright_one_of_pos_args_only(tmp_path: Path):
195195
)
196196

197197

198+
def test_register_random_protocol(tmp_path: Path):
199+
file = tmp_path / "test.py"
200+
file.write_text(
201+
textwrap.dedent(
202+
"""
203+
from random import Random
204+
from hypothesis import register_random
205+
206+
class MyRandom:
207+
def __init__(self) -> None:
208+
r = Random()
209+
self.seed = r.seed
210+
self.setstate = r.setstate
211+
self.getstate = r.getstate
212+
213+
register_random(MyRandom())
214+
register_random(None) # type: ignore
215+
"""
216+
)
217+
)
218+
_write_config(tmp_path, {"reportUnnecessaryTypeIgnoreComment": True})
219+
assert _get_pyright_errors(file) == []
220+
221+
198222
# ---------- Helpers for running pyright ---------- #
199223

200224

0 commit comments

Comments
 (0)