Skip to content

Commit 3b7c72e

Browse files
Change hypothesis check to be version based (#96)
* change hypothesis check to a version check * drop 3.8 support * add --mark-hypothesis-as-unsafe * guard against hypothesis not being installed --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent cced48f commit 3b7c72e

9 files changed

Lines changed: 102 additions & 58 deletions

File tree

.github/workflows/main.yml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ jobs:
1616
fail-fast: false
1717
matrix:
1818
python-version:
19-
- '3.8'
2019
- '3.9'
2120
- '3.10'
2221
- '3.11'
@@ -25,7 +24,7 @@ jobs:
2524
- '3.13t'
2625
- '3.14-dev'
2726
- '3.14t-dev'
28-
- 'pypy-3.8'
27+
- 'pypy-3.9'
2928
steps:
3029
- uses: actions/checkout@v4
3130
- uses: actions/setup-python@v5

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,6 @@ coverage.xml
5656
.pytest_cache/
5757
cover/
5858
*,cover
59-
.hypothesis/
6059
.pytest_cache
6160

6261
# Translations

README.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ those fixtures are shared between threads.
6161

6262
## Features
6363

64-
- Five global CLI flags:
64+
- Six global CLI flags:
6565
- `--parallel-threads` to run a test suite in parallel
6666
- `--iterations` to run multiple times in each thread
6767
- `--skip-thread-unsafe` to skip running tests marked as or
@@ -72,6 +72,14 @@ those fixtures are shared between threads.
7272
adding support for Python 3.14 to a library that already
7373
runs tests under pytest-run-parallel on Python 3.13 or
7474
older.
75+
- `--mark-hypothesis-as-unsafe`, to always skip runing tests that
76+
use [hypothesis](https://github.com/hypothesisworks/hypothesis).
77+
While newer version of Hypothesis are thread-safe, and versions
78+
which are not are automatically skipped by `pytest-run-parallel`,
79+
this flag is an escape hatch in case you run into thread-safety
80+
problems caused by Hypothesis, or in tests that happen to use
81+
hypothesis and were skipped in older versions of pytest-run-parallel.
82+
7583

7684
- Three corresponding markers:
7785
- `pytest.mark.parallel_threads(n)` to mark a single test to run

pyproject.toml

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ name = "pytest-run-parallel"
99
description = "A simple pytest plugin to run tests concurrently"
1010
version = "0.5.1-dev"
1111
readme = "README.md"
12-
requires-python = ">=3.8"
12+
requires-python = ">=3.9"
1313
dependencies = [
1414
"pytest>=6.2.0",
1515
]
@@ -28,7 +28,6 @@ classifiers = [
2828
"Topic :: Software Development :: Testing",
2929
"Operating System :: OS Independent",
3030
"Programming Language :: Python",
31-
"Programming Language :: Python :: 3.8",
3231
"Programming Language :: Python :: 3.9",
3332
"Programming Language :: Python :: 3.10",
3433
"Programming Language :: Python :: 3.11",
@@ -66,15 +65,15 @@ exclude = ["docs/conf.py"]
6665
select = ["E4", "E7", "E9", "F", "I"]
6766

6867
[tool.tox]
69-
env_list = ["py38", "py39", "py310", "py311", "py312", "py313", "py313t", "psutil", "pypy3", "ruff"]
68+
env_list = ["py39", "py310", "py311", "py312", "py313", "py313t", "psutil", "pypy3", "ruff"]
7069

7170
[tool.tox.env_run_base]
7271
deps = [
7372
"pytest>=6.2.0",
7473
"pytest-cov",
7574
"pytest-order",
7675
"check-manifest",
77-
"hypothesis",
76+
"hypothesis>=6.135.33",
7877
]
7978
commands = [
8079
[
@@ -104,11 +103,10 @@ extras = ["psutil"]
104103

105104

106105
[tool.tox.gh.python]
107-
"3.8" = ["py38"]
108106
"3.9" = ["py39"]
109107
"3.10" = ["py310"]
110108
"3.11" = ["py311"]
111109
"3.12" = ["py312"]
112110
"3.13" = ["py313", "psutil"]
113111
"3.13t" = ["py313t"]
114-
"pypy-3.8" = ["pypy3"]
112+
"pypy-3.9" = ["pypy3"]

src/pytest_run_parallel/plugin.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ def __init__(self, config):
9494
self.skip_thread_unsafe = config.option.skip_thread_unsafe
9595
self.mark_warnings_as_unsafe = config.option.mark_warnings_as_unsafe
9696
self.mark_ctypes_as_unsafe = config.option.mark_ctypes_as_unsafe
97+
self.mark_hypothesis_as_unsafe = config.option.mark_hypothesis_as_unsafe
9798

9899
skipped_functions = [
99100
x.split(".") for x in config.getini("thread_unsafe_functions")
@@ -140,6 +141,7 @@ def _is_thread_unsafe(self, item):
140141
self.skipped_functions,
141142
self.mark_warnings_as_unsafe,
142143
self.mark_ctypes_as_unsafe,
144+
self.mark_hypothesis_as_unsafe,
143145
)
144146

145147
@pytest.hookimpl(trylast=True)
@@ -332,6 +334,12 @@ def pytest_addoption(parser):
332334
dest="mark_ctypes_as_unsafe",
333335
default=False,
334336
)
337+
parser.addoption(
338+
"--mark-hypothesis-as-unsafe",
339+
action="store_true",
340+
dest="mark_hypothesis_as_unsafe",
341+
default=False,
342+
)
335343
parser.addini(
336344
"thread_unsafe_fixtures",
337345
"list of thread-unsafe fixture names that cause a test to "

src/pytest_run_parallel/thread_unsafe_detection.py

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ def is_hypothesis_test(fn):
1616
return False
1717

1818

19+
try:
20+
from hypothesis import __version_info__ as hypothesis_version
21+
except ImportError:
22+
hypothesis_version = (0, 0, 0)
23+
24+
HYPOTHESIS_THREADSAFE_VERSION = (6, 136, 3)
25+
1926
WARNINGS_IS_THREADSAFE = bool(
2027
getattr(sys.flags, "context_aware_warnings", 0)
2128
and getattr(sys.flags, "thread_inherit_context", 0)
@@ -47,7 +54,9 @@ def construct_base_blocklist(unsafe_warnings, unsafe_ctypes):
4754

4855

4956
class ThreadUnsafeNodeVisitor(ast.NodeVisitor):
50-
def __init__(self, fn, skip_set, unsafe_warnings, unsafe_ctypes, level=0):
57+
def __init__(
58+
self, fn, skip_set, unsafe_warnings, unsafe_ctypes, unsafe_hypothesis, level=0
59+
):
5160
self.thread_unsafe = False
5261
self.thread_unsafe_reason = None
5362
blocklist = construct_base_blocklist(unsafe_warnings, unsafe_ctypes)
@@ -64,6 +73,7 @@ def __init__(self, fn, skip_set, unsafe_warnings, unsafe_ctypes, level=0):
6473
self.skip_set = skip_set
6574
self.unsafe_warnings = unsafe_warnings
6675
self.unsafe_ctypes = unsafe_ctypes
76+
self.unsafe_hypothesis = unsafe_hypothesis
6777
self.level = level
6878
self.modules_aliases = {}
6979
self.func_aliases = {}
@@ -141,6 +151,7 @@ def _get_child_fn(mod, node):
141151
self.skip_set,
142152
self.unsafe_warnings,
143153
self.unsafe_ctypes,
154+
self.unsafe_hypothesis,
144155
self.level + 1,
145156
)
146157
)
@@ -192,6 +203,7 @@ def _recursive_analyze_name(self, node):
192203
self.skip_set,
193204
self.unsafe_warnings,
194205
self.unsafe_ctypes,
206+
self.unsafe_hypothesis,
195207
self.level + 1,
196208
)
197209
)
@@ -236,10 +248,22 @@ def visit(self, node):
236248

237249

238250
def _identify_thread_unsafe_nodes(
239-
fn, skip_set, unsafe_warnings, unsafe_ctypes, level=0
251+
fn, skip_set, unsafe_warnings, unsafe_ctypes, unsafe_hypothesis, level=0
240252
):
241253
if is_hypothesis_test(fn):
242-
return True, "uses hypothesis"
254+
if hypothesis_version < HYPOTHESIS_THREADSAFE_VERSION:
255+
return (
256+
True,
257+
f"uses hypothesis v{'.'.join(map(str, hypothesis_version))}, which "
258+
"is before the first thread-safe version "
259+
f"(v{'.'.join(map(str, HYPOTHESIS_THREADSAFE_VERSION))})",
260+
)
261+
if unsafe_hypothesis:
262+
return (
263+
True,
264+
"uses Hypothesis, and pytest-run-parallel was run with "
265+
"--mark-hypothesis-as-unsafe",
266+
)
243267

244268
try:
245269
src = inspect.getsource(fn)
@@ -252,7 +276,7 @@ def _identify_thread_unsafe_nodes(
252276
return False, None
253277

254278
visitor = ThreadUnsafeNodeVisitor(
255-
fn, skip_set, unsafe_warnings, unsafe_ctypes, level=level
279+
fn, skip_set, unsafe_warnings, unsafe_ctypes, unsafe_hypothesis, level=level
256280
)
257281
visitor.visit(tree)
258282
return visitor.thread_unsafe, visitor.thread_unsafe_reason
@@ -261,15 +285,11 @@ def _identify_thread_unsafe_nodes(
261285
cached_thread_unsafe_identify = functools.lru_cache(_identify_thread_unsafe_nodes)
262286

263287

264-
def identify_thread_unsafe_nodes(fn, skip_set, unsafe_warnings, unsafe_ctypes, level=0):
288+
def identify_thread_unsafe_nodes(*args, **kwargs):
265289
try:
266-
return cached_thread_unsafe_identify(
267-
fn, skip_set, unsafe_warnings, unsafe_ctypes, level=level
268-
)
290+
return cached_thread_unsafe_identify(*args, **kwargs)
269291
except TypeError:
270-
return _identify_thread_unsafe_nodes(
271-
fn, skip_set, unsafe_warnings, unsafe_ctypes, level=level
272-
)
292+
return _identify_thread_unsafe_nodes(*args, **kwargs)
273293

274294

275295
def construct_thread_unsafe_fixtures(config):

tests/test_run_parallel.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
import os
22

3+
import pytest
4+
5+
try:
6+
import hypothesis
7+
except ImportError:
8+
hypothesis = None
9+
310

411
def test_default_threads(pytester):
512
"""Make sure that pytest accepts our fixture."""
@@ -617,3 +624,21 @@ def test_parallel(num_parallel_threads):
617624
"*::test_doctests_marked_thread_unsafe.txt PASSED*",
618625
]
619626
)
627+
628+
629+
@pytest.mark.skipif(hypothesis is None, reason="hypothesis needs to be installed")
630+
def test_runs_hypothesis_in_parallel(pytester):
631+
pytester.makepyfile("""
632+
from hypothesis import given, strategies as st, settings, HealthCheck
633+
634+
@given(a=st.none())
635+
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
636+
def test_uses_hypothesis(a, num_parallel_threads):
637+
assert num_parallel_threads == 10
638+
""")
639+
result = pytester.runpytest("--parallel-threads=10", "-v")
640+
result.stdout.fnmatch_lines(
641+
[
642+
"*::test_uses_hypothesis PARALLEL PASSED*",
643+
]
644+
)

tests/test_thread_unsafe_detection.py

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -367,24 +367,6 @@ def test_should_be_marked_3(num_parallel_threads):
367367
)
368368

369369

370-
@pytest.mark.skipif(hypothesis is None, reason="hypothesis needs to be installed")
371-
def test_detect_hypothesis(pytester):
372-
pytester.makepyfile("""
373-
from hypothesis import given, strategies as st, settings, HealthCheck
374-
375-
@given(a=st.none())
376-
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
377-
def test_uses_hypothesis(a, num_parallel_threads):
378-
assert num_parallel_threads == 1
379-
""")
380-
result = pytester.runpytest("--parallel-threads=10", "-v")
381-
result.stdout.fnmatch_lines(
382-
[
383-
"*::test_uses_hypothesis PASSED*",
384-
]
385-
)
386-
387-
388370
def test_detect_unittest_mock(pytester):
389371
pytester.makepyfile("""
390372
import sys
@@ -688,3 +670,25 @@ def test_thread_unsafe_pytest_warns_multiline_string2(self, num_parallel_threads
688670
f"*::test_thread_unsafe_pytest_warns_multiline_string2 {WARNINGS_PASS}PASSED*",
689671
]
690672
)
673+
674+
675+
@pytest.mark.skipif(hypothesis is None, reason="hypothesis needs to be installed")
676+
def test_thread_unsafe_hypothesis_config_option(pytester):
677+
pytester.makepyfile("""
678+
from hypothesis import given, strategies as st, settings, HealthCheck
679+
680+
@given(st.integers())
681+
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
682+
def test_thread_unsafe_hypothesis(num_parallel_threads, n):
683+
assert num_parallel_threads == 1
684+
assert isinstance(n, int)
685+
""")
686+
687+
result = pytester.runpytest(
688+
"--parallel-threads=10", "-v", "--mark-hypothesis-as-unsafe"
689+
)
690+
result.stdout.fnmatch_lines(
691+
[
692+
"*::test_thread_unsafe_hypothesis PASSED*",
693+
]
694+
)

uv.lock

Lines changed: 2 additions & 19 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)