diff --git a/hypothesis-python/RELEASE.rst b/hypothesis-python/RELEASE.rst new file mode 100644 index 0000000000..3e6ae0513e --- /dev/null +++ b/hypothesis-python/RELEASE.rst @@ -0,0 +1,3 @@ +RELEASE_TYPE: patch + +Improve threading compatibility of an internal helper for managing deterministic rng seeding. diff --git a/hypothesis-python/src/hypothesis/core.py b/hypothesis-python/src/hypothesis/core.py index 32834b0035..a4691584f7 100644 --- a/hypothesis-python/src/hypothesis/core.py +++ b/hypothesis-python/src/hypothesis/core.py @@ -140,6 +140,7 @@ SearchStrategy, check_strategy, ) +from hypothesis.utils.threading import ThreadLocal from hypothesis.vendor.pretty import RepresentationPrinter from hypothesis.version import __version__ @@ -149,7 +150,11 @@ running_under_pytest = False pytest_shows_exceptiongroups = True global_force_seed = None -_hypothesis_global_random = None +# `threadlocal` stores "engine-global" constants, which are global relative to a +# ConjectureRunner instance (roughly speaking). Since only one conjecture runner +# instance can be active per thread, making engine constants thread-local prevents +# the ConjectureRunner instances of concurrent threads from treading on each other. +threadlocal = ThreadLocal(_hypothesis_global_random=None) @dataclass @@ -703,10 +708,9 @@ def get_random_for_wrapped_test(test, wrapped_test): elif global_force_seed is not None: return Random(global_force_seed) else: - global _hypothesis_global_random - if _hypothesis_global_random is None: # pragma: no cover - _hypothesis_global_random = Random() - seed = _hypothesis_global_random.getrandbits(128) + if threadlocal._hypothesis_global_random is None: # pragma: no cover + threadlocal._hypothesis_global_random = Random() + seed = threadlocal._hypothesis_global_random.getrandbits(128) wrapped_test._hypothesis_internal_use_generated_seed = seed return Random(seed) diff --git a/hypothesis-python/src/hypothesis/internal/entropy.py b/hypothesis-python/src/hypothesis/internal/entropy.py index d7ce1463bd..33ee1cba23 100644 --- a/hypothesis-python/src/hypothesis/internal/entropy.py +++ b/hypothesis-python/src/hypothesis/internal/entropy.py @@ -195,9 +195,11 @@ def deterministic_PRNG(seed: int = 0) -> Generator[None, None, None]: bad idea in principle, and breaks all kinds of independence assumptions in practice. """ - if hypothesis.core._hypothesis_global_random is None: # pragma: no cover - hypothesis.core._hypothesis_global_random = random.Random() - register_random(hypothesis.core._hypothesis_global_random) + if ( + hypothesis.core.threadlocal._hypothesis_global_random is None + ): # pragma: no cover + hypothesis.core.threadlocal._hypothesis_global_random = random.Random() + register_random(hypothesis.core.threadlocal._hypothesis_global_random) seed_all, restore_all = get_seeder_and_restorer(seed) seed_all() diff --git a/hypothesis-python/src/hypothesis/utils/threading.py b/hypothesis-python/src/hypothesis/utils/threading.py new file mode 100644 index 0000000000..ca8c03cce9 --- /dev/null +++ b/hypothesis-python/src/hypothesis/utils/threading.py @@ -0,0 +1,44 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. + +import threading +from typing import Any + + +class ThreadLocal: + """ + Manages thread-local state. ThreadLocal forwards getattr and setattr to a + threading.local() instance. The passed kwargs defines the available attributes + on the threadlocal and their default values. + + The only supported names to geattr and setattr are the keys of the passed kwargs. + """ + + def __init__(self, **kwargs: Any) -> None: + self.__initialized = False + self.__kwargs = kwargs + self.__threadlocal = threading.local() + self.__initialized = True + + def __getattr__(self, name: str) -> Any: + if name not in self.__kwargs: + raise AttributeError(f"No attribute {name}") + if not hasattr(self.__threadlocal, name): + setattr(self.__threadlocal, name, self.__kwargs[name]) + return getattr(self.__threadlocal, name) + + def __setattr__(self, name: str, value: Any) -> None: + # disable attribute-forwarding while initializing + if "_ThreadLocal__initialized" not in self.__dict__ or not self.__initialized: + super().__setattr__(name, value) + else: + if name not in self.__kwargs: + raise AttributeError(f"No attribute {name}") + setattr(self.__threadlocal, name, value) diff --git a/hypothesis-python/tests/cover/test_random_module.py b/hypothesis-python/tests/cover/test_random_module.py index 36b75c8b05..ba5396c314 100644 --- a/hypothesis-python/tests/cover/test_random_module.py +++ b/hypothesis-python/tests/cover/test_random_module.py @@ -133,11 +133,11 @@ def test(r): test() state_a = random.getstate() - state_a2 = core._hypothesis_global_random.getstate() + state_a2 = core.threadlocal._hypothesis_global_random.getstate() test() state_b = random.getstate() - state_b2 = core._hypothesis_global_random.getstate() + state_b2 = core.threadlocal._hypothesis_global_random.getstate() assert state_a == state_b assert state_a2 != state_b2 @@ -147,11 +147,11 @@ def test_find_does_not_pollute_state(): with deterministic_PRNG(): find(st.random_module(), lambda r: True) state_a = random.getstate() - state_a2 = core._hypothesis_global_random.getstate() + state_a2 = core.threadlocal._hypothesis_global_random.getstate() find(st.random_module(), lambda r: True) state_b = random.getstate() - state_b2 = core._hypothesis_global_random.getstate() + state_b2 = core.threadlocal._hypothesis_global_random.getstate() assert state_a == state_b assert state_a2 != state_b2 diff --git a/hypothesis-python/tests/cover/test_threading.py b/hypothesis-python/tests/cover/test_threading.py new file mode 100644 index 0000000000..c2f8757727 --- /dev/null +++ b/hypothesis-python/tests/cover/test_threading.py @@ -0,0 +1,47 @@ +# This file is part of Hypothesis, which may be found at +# https://github.com/HypothesisWorks/hypothesis/ +# +# Copyright the Hypothesis Authors. +# Individual contributors are listed in AUTHORS.rst and the git log. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, +# v. 2.0. If a copy of the MPL was not distributed with this file, You can +# obtain one at https://mozilla.org/MPL/2.0/. + +import pytest + +from hypothesis.utils.threading import ThreadLocal + + +def test_threadlocal_setattr_and_getattr(): + threadlocal = ThreadLocal(a=1, b=2) + assert threadlocal.a == 1 + assert threadlocal.b == 2 + # check that we didn't add attributes to the ThreadLocal instance itself + # instead of its threading.local() variable + assert set(threadlocal.__dict__) == { + "_ThreadLocal__initialized", + "_ThreadLocal__kwargs", + "_ThreadLocal__threadlocal", + } + + threadlocal.a = 3 + assert threadlocal.a == 3 + assert threadlocal.b == 2 + assert set(threadlocal.__dict__) == { + "_ThreadLocal__initialized", + "_ThreadLocal__kwargs", + "_ThreadLocal__threadlocal", + } + + +def test_nonexistent_getattr_raises(): + threadlocal = ThreadLocal(a=1) + with pytest.raises(AttributeError): + _c = threadlocal.c + + +def test_nonexistent_setattr_raises(): + threadlocal = ThreadLocal(a=1) + with pytest.raises(AttributeError): + threadlocal.c = 2 diff --git a/hypothesis-python/tests/nocover/test_randomization.py b/hypothesis-python/tests/nocover/test_randomization.py index 92c5ff71ed..6c0c0096ec 100644 --- a/hypothesis-python/tests/nocover/test_randomization.py +++ b/hypothesis-python/tests/nocover/test_randomization.py @@ -40,11 +40,11 @@ def f1(n): def f2(n): choices2.append(n) - core._hypothesis_global_random = Random(0) - state = core._hypothesis_global_random.getstate() + core.threadlocal._hypothesis_global_random = Random(0) + state = core.threadlocal._hypothesis_global_random.getstate() f1() - core._hypothesis_global_random.setstate(state) + core.threadlocal._hypothesis_global_random.setstate(state) f2() assert choices1 == choices2