Skip to content
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,13 +90,17 @@ those fixtures are shared between threads.
- `pytest.mark.iterations(n)` to mark a single test to run `n`
times in each thread

- And the corresponding fixtures:
- Four corresponding fixtures:
- `num_parallel_threads`: The number of threads the test will run in
- `num_iterations`: The number of iterations the test will run in
each thread
- `thread_index`: An index for the test's current thread.
- `iteration_index`: An index for the test's current iteration.

- Modifications to existing fixtures:
- `tmp_path` and `tmpdir`: Patched to be thread-safe, with individual
subdirectories being created for each thread and iteration.

**Note**: It's possible to specify `--parallel-threads=auto` or
`pytest.mark.parallel_threads("auto")` which will let
`pytest-run-parallel` choose the number of logical CPU cores available
Expand Down
42 changes: 38 additions & 4 deletions src/pytest_run_parallel/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,11 @@ def inner(*args, **kwargs):
def closure(*args, **kwargs):
# "smuggling" thread_index into closure with args
thread_index, args = args[0], args[1:]
if "thread_index" in kwargs:
kwargs["thread_index"] = thread_index
data = wrap_setup_thread(args, kwargs, thread_index)

for i in range(n_iterations):
if "iteration_index" in kwargs:
kwargs["iteration_index"] = i
wrap_setup_iteration(args, kwargs, i, n_iterations, data)

barrier.wait()
try:
fn(*args, **kwargs)
Expand Down Expand Up @@ -98,6 +97,8 @@ def closure(*args, **kwargs):
for worker in workers:
worker.join()

# if we ever want to add cleanup, put it here

finally:
sys.setswitchinterval(original_switch)

Expand All @@ -111,6 +112,39 @@ def closure(*args, **kwargs):
return inner


def wrap_setup_thread(args, kwargs, thread_index):
# modifies fixtures if needed for each thread
# thread_index: passes in thread index
# tmp_path: creates and sets to subdirectories for each thread
data = {}
Comment thread
bwhitt7 marked this conversation as resolved.
Outdated
if "thread_index" in kwargs:
kwargs["thread_index"] = thread_index
if "tmp_path" in kwargs:
Comment thread
bwhitt7 marked this conversation as resolved.
Outdated
kwargs["tmp_path"] = kwargs["tmp_path"] / f"thread_{str(thread_index)}"
Comment thread
bwhitt7 marked this conversation as resolved.
Outdated
kwargs["tmp_path"].mkdir()
data["thread_tmp_path"] = kwargs["tmp_path"]
if "tmpdir" in kwargs:
kwargs["tmpdir"] = kwargs["tmpdir"].mkdir(f"thread_{str(thread_index)}")
Comment thread
bwhitt7 marked this conversation as resolved.
Outdated
data["thread_tmpdir"] = kwargs["tmpdir"]
# can return data that is used later in the wrap function if needed.
# currently returns the new tmp_path, used later when adding
# subdirectories to tmp_path for iterations.
return data


def wrap_setup_iteration(args, kwargs, index, n_iterations, data):
# modifies fixtures if needed for each iteration
# iteration_index: passes in iteration index
# tmp_path: creates and sets to subdirectories for each iteration
if "iteration_index" in kwargs:
kwargs["iteration_index"] = index
if "tmp_path" in kwargs and n_iterations > 1:
kwargs["tmp_path"] = data["thread_tmp_path"] / f"iteration_{str(index)}"
kwargs["tmp_path"].mkdir()
if "tmpdir" in kwargs and n_iterations > 1:
kwargs["tmpdir"] = data["thread_tmpdir"].mkdir(f"iteration_{str(index)}")


class RunParallelPlugin:
def __init__(self, config):
self.verbose = bool(int(os.environ.get("PYTEST_RUN_PARALLEL_VERBOSE", "0")))
Expand Down
158 changes: 158 additions & 0 deletions tests/test_tmp_path.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import pytest

parallel_iterations = [
(1, 1, "PASSED"), # no parallel threads, no iterations
("auto", 1, "PARALLEL PASSED"), # parallel threads, no iterations
(1, 5, "PASSED"), # no parallel threads, iterations
("auto", 5, "PARALLEL PASSED"), # parallel threads and iterations
]


@pytest.mark.parametrize("parallel, iterations, passing", parallel_iterations)
def test_tmp_path_is_empty(pytester: pytest.Pytester, parallel, iterations, passing):
# ensures tmp_path is empty for each thread/iteration
# test from (gh-109)
pytester.makepyfile("""
def test_tmp_path(tmp_path):
assert tmp_path.exists()
assert tmp_path.is_dir()
assert len(list(tmp_path.iterdir())) == 0
d = tmp_path / "sub"
assert not d.exists()
d.mkdir()
assert d.exists()
""")

result = pytester.runpytest(
f"--parallel-threads={parallel}", f"--iterations={iterations}", "-v"
)

result.stdout.fnmatch_lines(
[
f"*::test_tmp_path {passing}*",
]
)


@pytest.mark.parametrize("parallel, iterations, passing", parallel_iterations)
def test_tmp_path_read_write(pytester: pytest.Pytester, parallel, iterations, passing):
# ensures we can read/write in each tmp_path
pytester.makepyfile("""
def test_tmp_path(tmp_path):
file = tmp_path / "file"
with open(file, "w") as f:
f.write("Hello world!")
assert file.is_file()
assert file.read_text() == "Hello world!"
""")

result = pytester.runpytest(
f"--parallel-threads={parallel}", f"--iterations={iterations}", "-v"
)

result.stdout.fnmatch_lines(
[
f"*::test_tmp_path {passing}*",
]
)


@pytest.mark.parametrize("parallel, iterations, passing", parallel_iterations)
def test_tmp_path_delete(pytester: pytest.Pytester, parallel, iterations, passing):
# ensures we can delete files in each tmp_path
pytester.makepyfile("""
def test_tmp_path(tmp_path):
subdir = tmp_path / "subdir"
subdir.mkdir()
file = subdir / "file"
with open(file, "w") as f:
f.write("Hello world!")
assert file.is_file()
file.unlink()
assert not file.exists()
subdir.rmdir()
assert not subdir.exists()
""")

result = pytester.runpytest(
f"--parallel-threads={parallel}", f"--iterations={iterations}", "-v"
)

result.stdout.fnmatch_lines(
[
f"*::test_tmp_path {passing}*",
]
)


@pytest.mark.parametrize("parallel, iterations, passing", parallel_iterations)
def test_tmpdir_is_empty(pytester: pytest.Pytester, parallel, iterations, passing):
# ensures tmpdir is empty for each thread/iteration
pytester.makepyfile("""
def test_tmpdir(tmpdir):
assert tmpdir.check()
assert tmpdir.check(dir=1)
assert len(list(tmpdir.listdir())) == 0
assert not tmpdir.join("sub").check()
assert tmpdir.mkdir("sub").check()
""")

result = pytester.runpytest(
f"--parallel-threads={parallel}", f"--iterations={iterations}", "-v"
)

result.stdout.fnmatch_lines(
[
f"*::test_tmpdir {passing}*",
]
)


@pytest.mark.parametrize("parallel, iterations, passing", parallel_iterations)
def test_tmpdir_read_write(pytester: pytest.Pytester, parallel, iterations, passing):
# ensures we can read/write in each tmpdir
pytester.makepyfile("""
def test_tmpdir(tmpdir):
file = tmpdir.join("file")
with open(file, "w") as f:
f.write("Hello world!")
assert file.check(file=1)
assert file.read_text("utf-8") == "Hello world!"
""")

result = pytester.runpytest(
f"--parallel-threads={parallel}", f"--iterations={iterations}", "-v"
)

result.stdout.fnmatch_lines(
[
f"*::test_tmpdir {passing}*",
]
)


@pytest.mark.parametrize("parallel, iterations, passing", parallel_iterations)
def test_tmpdir_delete(pytester: pytest.Pytester, parallel, iterations, passing):
# ensures we can delete files in each tmpdir
pytester.makepyfile("""
def test_tmpdir(tmpdir):
subdir = tmpdir.mkdir("sub")
file = tmpdir.join("file")
with open(file, "w") as f:
f.write("Hello world!")
assert file.check(file=1)
file.remove()
assert not file.check()
subdir.remove()
assert not subdir.check()
""")

result = pytester.runpytest(
f"--parallel-threads={parallel}", f"--iterations={iterations}", "-v"
)

result.stdout.fnmatch_lines(
[
f"*::test_tmpdir {passing}*",
]
)