Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ jobs:
fail-fast: false
matrix:
python-version:
- '3.8'
- '3.9'
- '3.10'
- '3.11'
Expand All @@ -25,7 +24,7 @@ jobs:
- '3.13t'
- '3.14-dev'
- '3.14t-dev'
- 'pypy-3.8'
- 'pypy-3.9'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ coverage.xml
.pytest_cache/
cover/
*,cover
.hypothesis/
.pytest_cache

# Translations
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ those fixtures are shared between threads.

## Features

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


- Three corresponding markers:
- `pytest.mark.parallel_threads(n)` to mark a single test to run
Expand Down
10 changes: 4 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ name = "pytest-run-parallel"
description = "A simple pytest plugin to run tests concurrently"
version = "0.5.1-dev"
readme = "README.md"
requires-python = ">=3.8"
requires-python = ">=3.9"
dependencies = [
"pytest>=6.2.0",
]
Expand All @@ -28,7 +28,6 @@ classifiers = [
"Topic :: Software Development :: Testing",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
Expand Down Expand Up @@ -66,15 +65,15 @@ exclude = ["docs/conf.py"]
select = ["E4", "E7", "E9", "F", "I"]

[tool.tox]
env_list = ["py38", "py39", "py310", "py311", "py312", "py313", "py313t", "psutil", "pypy3", "ruff"]
env_list = ["py39", "py310", "py311", "py312", "py313", "py313t", "psutil", "pypy3", "ruff"]

[tool.tox.env_run_base]
deps = [
"pytest>=6.2.0",
"pytest-cov",
"pytest-order",
"check-manifest",
"hypothesis",
"hypothesis>=6.135.33",
]
commands = [
[
Expand Down Expand Up @@ -104,11 +103,10 @@ extras = ["psutil"]


[tool.tox.gh.python]
"3.8" = ["py38"]
"3.9" = ["py39"]
"3.10" = ["py310"]
"3.11" = ["py311"]
"3.12" = ["py312"]
"3.13" = ["py313", "psutil"]
"3.13t" = ["py313t"]
"pypy-3.8" = ["pypy3"]
"pypy-3.9" = ["pypy3"]
8 changes: 8 additions & 0 deletions src/pytest_run_parallel/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ def __init__(self, config):
self.skip_thread_unsafe = config.option.skip_thread_unsafe
self.mark_warnings_as_unsafe = config.option.mark_warnings_as_unsafe
self.mark_ctypes_as_unsafe = config.option.mark_ctypes_as_unsafe
self.mark_hypothesis_as_unsafe = config.option.mark_hypothesis_as_unsafe

skipped_functions = [
x.split(".") for x in config.getini("thread_unsafe_functions")
Expand Down Expand Up @@ -140,6 +141,7 @@ def _is_thread_unsafe(self, item):
self.skipped_functions,
self.mark_warnings_as_unsafe,
self.mark_ctypes_as_unsafe,
self.mark_hypothesis_as_unsafe,
)

@pytest.hookimpl(trylast=True)
Expand Down Expand Up @@ -332,6 +334,12 @@ def pytest_addoption(parser):
dest="mark_ctypes_as_unsafe",
default=False,
)
parser.addoption(
"--mark-hypothesis-as-unsafe",
action="store_true",
dest="mark_hypothesis_as_unsafe",
default=False,
)
parser.addini(
"thread_unsafe_fixtures",
"list of thread-unsafe fixture names that cause a test to "
Expand Down
42 changes: 31 additions & 11 deletions src/pytest_run_parallel/thread_unsafe_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ def is_hypothesis_test(fn):
return False


try:
from hypothesis import __version_info__ as hypothesis_version
except ImportError:
hypothesis_version = (0, 0, 0)

HYPOTHESIS_THREADSAFE_VERSION = (6, 136, 3)

WARNINGS_IS_THREADSAFE = bool(
getattr(sys.flags, "context_aware_warnings", 0)
and getattr(sys.flags, "thread_inherit_context", 0)
Expand Down Expand Up @@ -47,7 +54,9 @@ def construct_base_blocklist(unsafe_warnings, unsafe_ctypes):


class ThreadUnsafeNodeVisitor(ast.NodeVisitor):
def __init__(self, fn, skip_set, unsafe_warnings, unsafe_ctypes, level=0):
def __init__(
self, fn, skip_set, unsafe_warnings, unsafe_ctypes, unsafe_hypothesis, level=0
):
self.thread_unsafe = False
self.thread_unsafe_reason = None
blocklist = construct_base_blocklist(unsafe_warnings, unsafe_ctypes)
Expand All @@ -64,6 +73,7 @@ def __init__(self, fn, skip_set, unsafe_warnings, unsafe_ctypes, level=0):
self.skip_set = skip_set
self.unsafe_warnings = unsafe_warnings
self.unsafe_ctypes = unsafe_ctypes
self.unsafe_hypothesis = unsafe_hypothesis
self.level = level
self.modules_aliases = {}
self.func_aliases = {}
Expand Down Expand Up @@ -141,6 +151,7 @@ def _get_child_fn(mod, node):
self.skip_set,
self.unsafe_warnings,
self.unsafe_ctypes,
self.unsafe_hypothesis,
self.level + 1,
)
)
Expand Down Expand Up @@ -192,6 +203,7 @@ def _recursive_analyze_name(self, node):
self.skip_set,
self.unsafe_warnings,
self.unsafe_ctypes,
self.unsafe_hypothesis,
self.level + 1,
)
)
Expand Down Expand Up @@ -236,10 +248,22 @@ def visit(self, node):


def _identify_thread_unsafe_nodes(
fn, skip_set, unsafe_warnings, unsafe_ctypes, level=0
fn, skip_set, unsafe_warnings, unsafe_ctypes, unsafe_hypothesis, level=0
):
if is_hypothesis_test(fn):
return True, "uses hypothesis"
if hypothesis_version < HYPOTHESIS_THREADSAFE_VERSION:
return (
True,
f"uses hypothesis v{'.'.join(map(str, hypothesis_version))}, which "
"is before the first thread-safe version "
f"(v{'.'.join(map(str, HYPOTHESIS_THREADSAFE_VERSION))})",
)
if unsafe_hypothesis:
return (
True,
"uses Hypothesis, and pytest-run-parallel was run with "
"--mark-hypothesis-as-unsafe",
)

try:
src = inspect.getsource(fn)
Expand All @@ -252,7 +276,7 @@ def _identify_thread_unsafe_nodes(
return False, None

visitor = ThreadUnsafeNodeVisitor(
fn, skip_set, unsafe_warnings, unsafe_ctypes, level=level
fn, skip_set, unsafe_warnings, unsafe_ctypes, unsafe_hypothesis, level=level
)
visitor.visit(tree)
return visitor.thread_unsafe, visitor.thread_unsafe_reason
Expand All @@ -261,15 +285,11 @@ def _identify_thread_unsafe_nodes(
cached_thread_unsafe_identify = functools.lru_cache(_identify_thread_unsafe_nodes)


def identify_thread_unsafe_nodes(fn, skip_set, unsafe_warnings, unsafe_ctypes, level=0):
def identify_thread_unsafe_nodes(*args, **kwargs):
try:
return cached_thread_unsafe_identify(
fn, skip_set, unsafe_warnings, unsafe_ctypes, level=level
)
return cached_thread_unsafe_identify(*args, **kwargs)
except TypeError:
return _identify_thread_unsafe_nodes(
fn, skip_set, unsafe_warnings, unsafe_ctypes, level=level
)
return _identify_thread_unsafe_nodes(*args, **kwargs)


def construct_thread_unsafe_fixtures(config):
Expand Down
25 changes: 25 additions & 0 deletions tests/test_run_parallel.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import os

import pytest

try:
import hypothesis
except ImportError:
hypothesis = None


def test_default_threads(pytester):
"""Make sure that pytest accepts our fixture."""
Expand Down Expand Up @@ -617,3 +624,21 @@ def test_parallel(num_parallel_threads):
"*::test_doctests_marked_thread_unsafe.txt PASSED*",
]
)


@pytest.mark.skipif(hypothesis is None, reason="hypothesis needs to be installed")
def test_runs_hypothesis_in_parallel(pytester):
pytester.makepyfile("""
from hypothesis import given, strategies as st, settings, HealthCheck

@given(a=st.none())
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_uses_hypothesis(a, num_parallel_threads):
assert num_parallel_threads == 10
""")
result = pytester.runpytest("--parallel-threads=10", "-v")
result.stdout.fnmatch_lines(
[
"*::test_uses_hypothesis PARALLEL PASSED*",
]
)
40 changes: 22 additions & 18 deletions tests/test_thread_unsafe_detection.py
Original file line number Diff line number Diff line change
Expand Up @@ -367,24 +367,6 @@ def test_should_be_marked_3(num_parallel_threads):
)


@pytest.mark.skipif(hypothesis is None, reason="hypothesis needs to be installed")
def test_detect_hypothesis(pytester):
pytester.makepyfile("""
from hypothesis import given, strategies as st, settings, HealthCheck

@given(a=st.none())
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_uses_hypothesis(a, num_parallel_threads):
assert num_parallel_threads == 1
""")
result = pytester.runpytest("--parallel-threads=10", "-v")
result.stdout.fnmatch_lines(
[
"*::test_uses_hypothesis PASSED*",
]
)


def test_detect_unittest_mock(pytester):
pytester.makepyfile("""
import sys
Expand Down Expand Up @@ -688,3 +670,25 @@ def test_thread_unsafe_pytest_warns_multiline_string2(self, num_parallel_threads
f"*::test_thread_unsafe_pytest_warns_multiline_string2 {WARNINGS_PASS}PASSED*",
]
)


@pytest.mark.skipif(hypothesis is None, reason="hypothesis needs to be installed")
def test_thread_unsafe_hypothesis_config_option(pytester):
pytester.makepyfile("""
from hypothesis import given, strategies as st, settings, HealthCheck

@given(st.integers())
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_thread_unsafe_hypothesis(num_parallel_threads, n):
assert num_parallel_threads == 1
assert isinstance(n, int)
""")

result = pytester.runpytest(
"--parallel-threads=10", "-v", "--mark-hypothesis-as-unsafe"
)
result.stdout.fnmatch_lines(
[
"*::test_thread_unsafe_hypothesis PASSED*",
]
)
21 changes: 2 additions & 19 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading