pytest-threadpool 0.3.0__tar.gz → 0.3.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/.github/workflows/ci.yml +5 -0
- pytest_threadpool-0.3.3/.github/workflows/publish.yml +25 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/.gitignore +3 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/CONTRIBUTING.md +35 -20
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/PKG-INFO +11 -12
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/README.md +10 -10
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/pyproject.toml +11 -4
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/src/pytest_threadpool/_runner.py +72 -32
- pytest_threadpool-0.3.3/src/pytest_threadpool/_version.py +34 -0
- pytest_threadpool-0.3.3/tests/integration_tests/cases/collected_count.py +23 -0
- pytest_threadpool-0.3.3/tests/integration_tests/cases/teardown_after_test_failure.py +38 -0
- pytest_threadpool-0.3.3/tests/integration_tests/cases/teardown_parallel_timing.py +55 -0
- pytest_threadpool-0.3.3/tests/integration_tests/cases/teardown_same_thread.py +50 -0
- pytest_threadpool-0.3.3/tests/integration_tests/cases/teardown_xunit_function.py +51 -0
- pytest_threadpool-0.3.3/tests/integration_tests/cases/teardown_xunit_method_thread.py +49 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/conftest.py +9 -0
- pytest_threadpool-0.3.3/tests/integration_tests/test_pytester_parallel_teardown.py +42 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_reporting.py +9 -0
- pytest_threadpool-0.3.0/.claude/settings.local.json +0 -30
- pytest_threadpool-0.3.0/ANALYSIS.md +0 -142
- pytest_threadpool-0.3.0/CLAUDE.md +0 -0
- pytest_threadpool-0.3.0/PLAN.md +0 -223
- pytest_threadpool-0.3.0/TODO.md +0 -32
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/LICENSE +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/hooks/pre-commit +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/scripts/setup-dev +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/src/pytest_threadpool/__init__.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/src/pytest_threadpool/_api.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/src/pytest_threadpool/_constants.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/src/pytest_threadpool/_fixtures.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/src/pytest_threadpool/_grouping.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/src/pytest_threadpool/_markers.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/src/pytest_threadpool/plugin.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/__init__.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/__init__.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/__init__.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/_templates.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/class_barrier_concurrency.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/class_single_method.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/class_thread_verification.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/edge_cross_module_group.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/edge_keyboard_interrupt.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/edge_nested_threads.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/edge_sigint.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/edge_sigint_many.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/edge_system_exit.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_autouse_function.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_class_scoped_once.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_class_yield.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_func_addfinalizer.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_func_chain.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_func_multiple_per_test.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_func_setup_parallel.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_func_with_shared_deps.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_func_with_tmp_path.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_func_with_xunit.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_func_yield_teardown.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_function_scoped.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_interdependent_finalizers.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_multiple_scopes.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_parameterized.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_teardown_exception.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/fixture_yield_cleanup.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/marks_custom.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/marks_standard.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/parallel_only_skip.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/reporting_cross_module.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/reporting_incremental.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/reporting_incremental_conftest.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/reporting_package_children.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_all_merged.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_all_not_parallelizable.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_children_separate_params.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_dynamic_parametrize.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_method_overrides_class.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_module_children.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_not_parallelizable_function.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_not_parallelizable_method.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/scope_parameters.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/sequential_bare_functions.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/sequential_unmarked_class.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/setup_all_fail.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/setup_mixed_pass_fail.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/shared_counter.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/shared_cross_group_non_pickleable.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/shared_dict_mutation.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/shared_non_pickleable.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/shared_two_phase_barrier.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/validate_threadpool.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/validate_threadpool_conftest.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/xunit_class_setup.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/xunit_combined_setup.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/xunit_function_setup.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/xunit_method_setup.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/xunit_method_teardown_runs.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/cases/xunit_module_setup.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_class.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_edge_cases.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_fixtures.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_func_fixtures.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_marks.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_package.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_scopes.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_sequential.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_shared.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/integration_tests/test_pytester_xunit.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/unit_tests/__init__.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/unit_tests/test_unit_api.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/unit_tests/test_unit_fixtures.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/unit_tests/test_unit_grouping.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/unit_tests/test_unit_markers.py +0 -0
- {pytest_threadpool-0.3.0 → pytest_threadpool-0.3.3}/tests/unit_tests/test_unit_plugin.py +0 -0
|
@@ -5,12 +5,15 @@ on:
|
|
|
5
5
|
branches: [main]
|
|
6
6
|
pull_request:
|
|
7
7
|
branches: [main]
|
|
8
|
+
workflow_call:
|
|
8
9
|
|
|
9
10
|
jobs:
|
|
10
11
|
lint:
|
|
11
12
|
runs-on: ubuntu-latest
|
|
12
13
|
steps:
|
|
13
14
|
- uses: actions/checkout@v4
|
|
15
|
+
with:
|
|
16
|
+
fetch-depth: 0
|
|
14
17
|
- uses: astral-sh/setup-uv@v6
|
|
15
18
|
- run: uv sync --python 3.14t --group dev
|
|
16
19
|
- run: uv run ruff format --check src/ tests/
|
|
@@ -25,6 +28,8 @@ jobs:
|
|
|
25
28
|
python: ["3.13t", "3.14t", "3.15t"]
|
|
26
29
|
steps:
|
|
27
30
|
- uses: actions/checkout@v4
|
|
31
|
+
with:
|
|
32
|
+
fetch-depth: 0
|
|
28
33
|
- uses: astral-sh/setup-uv@v6
|
|
29
34
|
- run: uv python install ${{ matrix.python }}
|
|
30
35
|
- run: uv sync --python ${{ matrix.python }} --group dev
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
name: Publish to PyPI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
tags: ["v*"]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
ci:
|
|
9
|
+
uses: ./.github/workflows/ci.yml
|
|
10
|
+
secrets: inherit
|
|
11
|
+
|
|
12
|
+
publish:
|
|
13
|
+
needs: ci
|
|
14
|
+
runs-on: ubuntu-latest
|
|
15
|
+
environment: pypi
|
|
16
|
+
permissions:
|
|
17
|
+
id-token: write
|
|
18
|
+
steps:
|
|
19
|
+
- uses: actions/checkout@v4
|
|
20
|
+
with:
|
|
21
|
+
fetch-depth: 0
|
|
22
|
+
- uses: astral-sh/setup-uv@v6
|
|
23
|
+
- run: uv build
|
|
24
|
+
- name: Publish to PyPI
|
|
25
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -40,10 +40,13 @@ Any mutable state needed across tests or across a test run must be held as
|
|
|
40
40
|
class attributes or instance attributes — never as module-level variables.
|
|
41
41
|
|
|
42
42
|
```python
|
|
43
|
+
import threading
|
|
44
|
+
|
|
43
45
|
# Good
|
|
44
46
|
class TestState:
|
|
45
|
-
|
|
46
|
-
|
|
47
|
+
log = {}
|
|
48
|
+
barrier = threading.Barrier(3, timeout=10)
|
|
49
|
+
|
|
47
50
|
|
|
48
51
|
# Bad
|
|
49
52
|
_log = {}
|
|
@@ -67,9 +70,11 @@ subprocess with the exact thread count needed. Use explicit thread counts for
|
|
|
67
70
|
barrier-based tests (not `auto`) to avoid deadlocks on low-core machines.
|
|
68
71
|
|
|
69
72
|
```python
|
|
73
|
+
import threading
|
|
74
|
+
|
|
70
75
|
# Good — isolated subprocess via ftdir, explicit thread count
|
|
71
76
|
def test_children_run_concurrently(self, ftdir):
|
|
72
|
-
|
|
77
|
+
ftdir.makepyfile("""
|
|
73
78
|
import threading, pytest
|
|
74
79
|
@pytest.mark.parallelizable("children")
|
|
75
80
|
class TestBarrier:
|
|
@@ -77,16 +82,25 @@ def test_children_run_concurrently(self, ftdir):
|
|
|
77
82
|
def test_a(self): self.barrier.wait()
|
|
78
83
|
def test_b(self): self.barrier.wait()
|
|
79
84
|
""")
|
|
80
|
-
|
|
81
|
-
|
|
85
|
+
result = ftdir.run_pytest("--threadpool", "2")
|
|
86
|
+
result.assert_outcomes(passed=2)
|
|
87
|
+
|
|
82
88
|
|
|
83
89
|
# Bad — depends on outer runner flags
|
|
84
90
|
class TestBarrier:
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
91
|
+
barrier = threading.Barrier(2, timeout=10)
|
|
92
|
+
|
|
93
|
+
def test_a(self): self.barrier.wait()
|
|
94
|
+
|
|
95
|
+
def test_b(self): self.barrier.wait()
|
|
88
96
|
```
|
|
89
97
|
|
|
98
|
+
### Use `pytest.fail()` for intentional failures
|
|
99
|
+
|
|
100
|
+
In test cases that need to intentionally fail (e.g., verifying teardown after
|
|
101
|
+
failure), use `pytest.fail("reason")` instead of `assert False, "reason"`.
|
|
102
|
+
The latter triggers ruff PT015 and B011.
|
|
103
|
+
|
|
90
104
|
### Constants over strings
|
|
91
105
|
|
|
92
106
|
Use `ParallelScope` enum and `_constants` module instead of bare string
|
|
@@ -128,18 +142,19 @@ Every commit message starts with one or more tags in brackets:
|
|
|
128
142
|
|
|
129
143
|
### Tags
|
|
130
144
|
|
|
131
|
-
| Tag
|
|
132
|
-
|
|
133
|
-
| `[Runner]`
|
|
134
|
-
| `[Markers]`
|
|
135
|
-
| `[Report]`
|
|
136
|
-
| `[Plugin]`
|
|
137
|
-
| `[API]`
|
|
138
|
-
| `[Test]`
|
|
139
|
-
| `[
|
|
140
|
-
| `[
|
|
141
|
-
| `[
|
|
142
|
-
| `[
|
|
145
|
+
| Tag | Scope |
|
|
146
|
+
|--------------|---------------------------------------------------------------------------------|
|
|
147
|
+
| `[Runner]` | Core parallel execution engine (`_runner.py`, `_fixtures.py`) |
|
|
148
|
+
| `[Markers]` | Marker resolution and grouping (`_markers.py`, `_grouping.py`, `_constants.py`) |
|
|
149
|
+
| `[Report]` | Terminal reporting and display (`_LiveReporter`) |
|
|
150
|
+
| `[Plugin]` | Plugin hooks and CLI options (`plugin.py`) |
|
|
151
|
+
| `[API]` | Public API changes (`_api.py`, `__init__.py`) |
|
|
152
|
+
| `[Test]` | Test additions or changes only |
|
|
153
|
+
| `[Build]` | CI/CD pipelines, publishing, versioning |
|
|
154
|
+
| `[Tooling]` | Ruff, pyright, pre-commit, scripts |
|
|
155
|
+
| `[Docs]` | README, CONTRIBUTING, docstrings |
|
|
156
|
+
| `[Refactor]` | Internal restructuring, no behavior change |
|
|
157
|
+
| `[Fix]` | Bug fix — must include issue number if fixing a known issue |
|
|
143
158
|
|
|
144
159
|
### Combining tags
|
|
145
160
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-threadpool
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Parallel test execution for free-threaded Python builds
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -13,13 +13,12 @@ Classifier: Programming Language :: Python :: 3
|
|
|
13
13
|
Classifier: Topic :: Software Development :: Testing
|
|
14
14
|
Requires-Python: >=3.13
|
|
15
15
|
Requires-Dist: pytest>=9.0.2
|
|
16
|
-
Requires-Dist: selenium>=4.41.0
|
|
17
16
|
Description-Content-Type: text/markdown
|
|
18
17
|
|
|
19
18
|
# pytest-threadpool
|
|
20
19
|
|
|
21
|
-

|
|
22
|
-

|
|
20
|
+
[](https://pypi.org/project/pytest-threadpool/)
|
|
21
|
+
[](https://pypi.org/project/pytest-threadpool/)
|
|
23
22
|
[](https://github.com/pytest-threadpool/pytest-threadpool/blob/main/LICENSE)
|
|
24
23
|
[](https://github.com/pytest-threadpool/pytest-threadpool)
|
|
25
24
|
[](https://github.com/pytest-threadpool/pytest-threadpool/actions/workflows/ci.yml)
|
|
@@ -27,9 +26,9 @@ Description-Content-Type: text/markdown
|
|
|
27
26
|
|
|
28
27
|
**Status: Beta** · Parallel test execution for free-threaded Python builds (3.13t+).
|
|
29
28
|
|
|
30
|
-
Runs test *bodies
|
|
31
|
-
pool while keeping shared fixtures
|
|
32
|
-
sequential.
|
|
29
|
+
Runs test *bodies*, function-scoped fixture setup, and function-scoped fixture
|
|
30
|
+
teardown concurrently in a thread pool while keeping shared fixtures
|
|
31
|
+
(module/class/session scope) sequential.
|
|
33
32
|
|
|
34
33
|
## Installation
|
|
35
34
|
|
|
@@ -88,12 +87,12 @@ sequentially before workers launch and served from cache to all items.
|
|
|
88
87
|
|
|
89
88
|
| Scope | Behavior |
|
|
90
89
|
|------------|-------------------------------------------------|
|
|
91
|
-
| `function` | Cloned per-item,
|
|
92
|
-
| `class` | Resolved once, cached, shared across items
|
|
93
|
-
| `module` | Resolved once, cached, shared across items
|
|
94
|
-
| `session` | Resolved once, cached, shared across items
|
|
90
|
+
| `function` | Cloned per-item, setup and teardown in parallel workers |
|
|
91
|
+
| `class` | Resolved once, cached, shared across items |
|
|
92
|
+
| `module` | Resolved once, cached, shared across items |
|
|
93
|
+
| `session` | Resolved once, cached, shared across items |
|
|
95
94
|
|
|
96
|
-
|
|
95
|
+
Shared fixture teardown runs sequentially after the parallel group completes.
|
|
97
96
|
|
|
98
97
|
## Marker levels
|
|
99
98
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# pytest-threadpool
|
|
2
2
|
|
|
3
|
-

|
|
4
|
-

|
|
3
|
+
[](https://pypi.org/project/pytest-threadpool/)
|
|
4
|
+
[](https://pypi.org/project/pytest-threadpool/)
|
|
5
5
|
[](https://github.com/pytest-threadpool/pytest-threadpool/blob/main/LICENSE)
|
|
6
6
|
[](https://github.com/pytest-threadpool/pytest-threadpool)
|
|
7
7
|
[](https://github.com/pytest-threadpool/pytest-threadpool/actions/workflows/ci.yml)
|
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
|
|
10
10
|
**Status: Beta** · Parallel test execution for free-threaded Python builds (3.13t+).
|
|
11
11
|
|
|
12
|
-
Runs test *bodies
|
|
13
|
-
pool while keeping shared fixtures
|
|
14
|
-
sequential.
|
|
12
|
+
Runs test *bodies*, function-scoped fixture setup, and function-scoped fixture
|
|
13
|
+
teardown concurrently in a thread pool while keeping shared fixtures
|
|
14
|
+
(module/class/session scope) sequential.
|
|
15
15
|
|
|
16
16
|
## Installation
|
|
17
17
|
|
|
@@ -70,12 +70,12 @@ sequentially before workers launch and served from cache to all items.
|
|
|
70
70
|
|
|
71
71
|
| Scope | Behavior |
|
|
72
72
|
|------------|-------------------------------------------------|
|
|
73
|
-
| `function` | Cloned per-item,
|
|
74
|
-
| `class` | Resolved once, cached, shared across items
|
|
75
|
-
| `module` | Resolved once, cached, shared across items
|
|
76
|
-
| `session` | Resolved once, cached, shared across items
|
|
73
|
+
| `function` | Cloned per-item, setup and teardown in parallel workers |
|
|
74
|
+
| `class` | Resolved once, cached, shared across items |
|
|
75
|
+
| `module` | Resolved once, cached, shared across items |
|
|
76
|
+
| `session` | Resolved once, cached, shared across items |
|
|
77
77
|
|
|
78
|
-
|
|
78
|
+
Shared fixture teardown runs sequentially after the parallel group completes.
|
|
79
79
|
|
|
80
80
|
## Marker levels
|
|
81
81
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "pytest-threadpool"
|
|
3
|
-
|
|
3
|
+
dynamic = ["version"]
|
|
4
4
|
description = "Parallel test execution for free-threaded Python builds"
|
|
5
5
|
requires-python = ">=3.13"
|
|
6
6
|
license = "MIT"
|
|
@@ -16,7 +16,6 @@ classifiers = [
|
|
|
16
16
|
]
|
|
17
17
|
dependencies = [
|
|
18
18
|
"pytest>=9.0.2",
|
|
19
|
-
"selenium>=4.41.0",
|
|
20
19
|
]
|
|
21
20
|
|
|
22
21
|
[project.entry-points.pytest11]
|
|
@@ -30,24 +29,32 @@ dev = [
|
|
|
30
29
|
]
|
|
31
30
|
|
|
32
31
|
[build-system]
|
|
33
|
-
requires = ["hatchling"]
|
|
32
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
34
33
|
build-backend = "hatchling.build"
|
|
35
34
|
|
|
35
|
+
[tool.hatch.version]
|
|
36
|
+
source = "vcs"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.hooks.vcs]
|
|
39
|
+
version-file = "src/pytest_threadpool/_version.py"
|
|
40
|
+
|
|
36
41
|
[tool.hatch.build.targets.wheel]
|
|
37
42
|
packages = ["src/pytest_threadpool"]
|
|
38
43
|
|
|
39
44
|
[tool.pytest.ini_options]
|
|
40
45
|
addopts = ["--threadpool", "auto"]
|
|
46
|
+
norecursedirs = ["cases"]
|
|
41
47
|
|
|
42
48
|
[tool.pyright]
|
|
43
49
|
pythonVersion = "3.13"
|
|
44
50
|
include = ["src", "tests"]
|
|
45
51
|
typeCheckingMode = "standard"
|
|
46
52
|
reportPrivateUsage = false
|
|
47
|
-
ignore = ["tests/unit_tests"]
|
|
53
|
+
ignore = ["tests/unit_tests", "src/pytest_threadpool/_version.py"]
|
|
48
54
|
|
|
49
55
|
[tool.ruff]
|
|
50
56
|
target-version = "py313"
|
|
57
|
+
exclude = ["src/pytest_threadpool/_version.py"]
|
|
51
58
|
src = ["src", "tests"]
|
|
52
59
|
line-length = 99
|
|
53
60
|
|
|
@@ -394,7 +394,7 @@ class ParallelRunner:
|
|
|
394
394
|
ihook.pytest_runtest_logreport(report=setup_reports[item])
|
|
395
395
|
if setup_passed[item] and session.config.getoption("setupshow", False):
|
|
396
396
|
show_test_item(item)
|
|
397
|
-
self._teardown_all(items, per_item_fixture_fins, saved_collector_fins)
|
|
397
|
+
self._teardown_all(items, per_item_fixture_fins, {}, saved_collector_fins)
|
|
398
398
|
return
|
|
399
399
|
|
|
400
400
|
# Phase 1+2: fire hooks for all items, populate shared fixture caches,
|
|
@@ -470,8 +470,8 @@ class ParallelRunner:
|
|
|
470
470
|
|
|
471
471
|
cancelled = threading.Event()
|
|
472
472
|
|
|
473
|
-
def
|
|
474
|
-
"""Setup + call worker (for items with cloned FixtureDefs).
|
|
473
|
+
def _do_setup_call_teardown(test_item):
|
|
474
|
+
"""Setup + call + teardown worker (for items with cloned FixtureDefs).
|
|
475
475
|
|
|
476
476
|
Fixture setup uses cloned function-scoped FixtureDefs so each
|
|
477
477
|
worker creates independent fixture instances without racing
|
|
@@ -479,13 +479,17 @@ class ParallelRunner:
|
|
|
479
479
|
from the first item's sequential setup, so their FixtureDef.execute()
|
|
480
480
|
is a cache-hit read.
|
|
481
481
|
|
|
482
|
+
Function-scoped fixture teardown (yield cleanup, addfinalizer
|
|
483
|
+
callbacks, xunit teardown_method) also runs in the worker since
|
|
484
|
+
each item's finalizers are independent.
|
|
485
|
+
|
|
482
486
|
addfinalizer is redirected to a per-item list to avoid the
|
|
483
487
|
setupstate stack assertion (the item IS in the stack, but
|
|
484
488
|
list.append on the stack entry is also safe on free-threaded Python;
|
|
485
489
|
the redirect simply avoids coupling to setupstate internals).
|
|
486
490
|
"""
|
|
487
491
|
if cancelled.is_set():
|
|
488
|
-
return test_item, None, None,
|
|
492
|
+
return test_item, None, None, None
|
|
489
493
|
|
|
490
494
|
# Redirect node.addfinalizer to per-item list
|
|
491
495
|
original_addfinalizer = test_item.addfinalizer
|
|
@@ -499,16 +503,35 @@ class ParallelRunner:
|
|
|
499
503
|
|
|
500
504
|
if setup_info.excinfo is None:
|
|
501
505
|
fixture_fins = FixtureManager.save_and_clear_function_fixtures(test_item)
|
|
506
|
+
call_info = None
|
|
502
507
|
if not cancelled.is_set():
|
|
503
508
|
live.mark_running(test_item)
|
|
504
509
|
call_info = CallInfo.from_call(lambda: test_item.runtest(), when="call")
|
|
505
510
|
if not cancelled.is_set():
|
|
506
511
|
live.mark_call_done(test_item, call_info.excinfo)
|
|
507
|
-
|
|
508
|
-
|
|
512
|
+
|
|
513
|
+
# Run function-scoped fixture teardown in the worker.
|
|
514
|
+
# Includes yield cleanup, addfinalizer callbacks, and
|
|
515
|
+
# node-level finalizers captured during setup.
|
|
516
|
+
all_fins = list(node_fins) + fixture_fins
|
|
517
|
+
|
|
518
|
+
def _run_fins(fns=all_fins):
|
|
519
|
+
exceptions = []
|
|
520
|
+
for fn in reversed(fns):
|
|
521
|
+
try:
|
|
522
|
+
fn()
|
|
523
|
+
except BaseException as e:
|
|
524
|
+
exceptions.append(e)
|
|
525
|
+
if len(exceptions) == 1:
|
|
526
|
+
raise exceptions[0]
|
|
527
|
+
if exceptions:
|
|
528
|
+
raise BaseExceptionGroup("errors during fixture teardown", exceptions)
|
|
529
|
+
|
|
530
|
+
teardown_info = CallInfo.from_call(_run_fins, when="teardown")
|
|
531
|
+
return test_item, setup_info, call_info, teardown_info
|
|
509
532
|
|
|
510
533
|
FixtureManager.clear_function_fixture_caches(test_item)
|
|
511
|
-
return test_item, setup_info, None,
|
|
534
|
+
return test_item, setup_info, None, None
|
|
512
535
|
|
|
513
536
|
def _report_item(item):
|
|
514
537
|
"""Report a single item — live cursor mode or plain fallback."""
|
|
@@ -536,6 +559,8 @@ class ParallelRunner:
|
|
|
536
559
|
|
|
537
560
|
reported.add(item)
|
|
538
561
|
|
|
562
|
+
teardown_infos = {}
|
|
563
|
+
|
|
539
564
|
interrupted = False
|
|
540
565
|
if workers > 1 and len(parallel_items) > 1:
|
|
541
566
|
work_queue = queue.SimpleQueue()
|
|
@@ -547,14 +572,15 @@ class ParallelRunner:
|
|
|
547
572
|
if work_item is None or cancelled.is_set():
|
|
548
573
|
return
|
|
549
574
|
try:
|
|
550
|
-
item, setup_info, call_info,
|
|
551
|
-
per_item_fixture_fins[item] = fixture_fins
|
|
575
|
+
item, setup_info, call_info, td_info = _do_setup_call_teardown(work_item)
|
|
552
576
|
if setup_info is not None:
|
|
553
577
|
setup_rep = item.ihook.pytest_runtest_makereport(
|
|
554
578
|
item=item, call=setup_info
|
|
555
579
|
)
|
|
556
580
|
setup_reports[item] = setup_rep
|
|
557
581
|
setup_passed[item] = setup_info.excinfo is None
|
|
582
|
+
if td_info is not None:
|
|
583
|
+
teardown_infos[item] = td_info
|
|
558
584
|
result_queue.put((item, call_info))
|
|
559
585
|
except BaseException as exc:
|
|
560
586
|
call_info = CallInfo.from_call(
|
|
@@ -569,7 +595,7 @@ class ParallelRunner:
|
|
|
569
595
|
t.start()
|
|
570
596
|
threads.append(t)
|
|
571
597
|
|
|
572
|
-
# Submit all parallel items (setup + call)
|
|
598
|
+
# Submit all parallel items (setup + call + teardown)
|
|
573
599
|
for item in parallel_items:
|
|
574
600
|
work_queue.put(item)
|
|
575
601
|
|
|
@@ -591,17 +617,18 @@ class ParallelRunner:
|
|
|
591
617
|
for t in threads:
|
|
592
618
|
t.join()
|
|
593
619
|
else:
|
|
594
|
-
# Single worker fallback
|
|
620
|
+
# Single worker fallback
|
|
595
621
|
try:
|
|
596
622
|
for item in parallel_items:
|
|
597
|
-
item, setup_info, call_info,
|
|
598
|
-
per_item_fixture_fins[item] = fixture_fins
|
|
623
|
+
item, setup_info, call_info, td_info = _do_setup_call_teardown(item)
|
|
599
624
|
if setup_info is not None:
|
|
600
625
|
setup_rep = item.ihook.pytest_runtest_makereport(
|
|
601
626
|
item=item, call=setup_info
|
|
602
627
|
)
|
|
603
628
|
setup_reports[item] = setup_rep
|
|
604
629
|
setup_passed[item] = setup_info.excinfo is None
|
|
630
|
+
if td_info is not None:
|
|
631
|
+
teardown_infos[item] = td_info
|
|
605
632
|
if call_info is not None:
|
|
606
633
|
call_results[item] = call_info
|
|
607
634
|
_report_item(item)
|
|
@@ -622,33 +649,46 @@ class ParallelRunner:
|
|
|
622
649
|
if item in session._setupstate.stack: # pyright: ignore[reportPrivateUsage]
|
|
623
650
|
session._setupstate.stack.pop(item) # pyright: ignore[reportPrivateUsage]
|
|
624
651
|
|
|
625
|
-
# Teardown (always runs, even after interrupt)
|
|
626
|
-
self._teardown_all(items, per_item_fixture_fins, saved_collector_fins)
|
|
652
|
+
# Teardown reporting + collector teardown (always runs, even after interrupt)
|
|
653
|
+
self._teardown_all(items, per_item_fixture_fins, teardown_infos, saved_collector_fins)
|
|
627
654
|
|
|
628
655
|
if interrupted:
|
|
629
656
|
raise KeyboardInterrupt
|
|
630
657
|
|
|
631
|
-
def _teardown_all(
|
|
632
|
-
|
|
633
|
-
|
|
658
|
+
def _teardown_all(
|
|
659
|
+
self, items, per_item_fixture_fins, teardown_infos, saved_collector_fins
|
|
660
|
+
) -> None:
|
|
661
|
+
"""Report teardown results, run any remaining finalizers, and tear down
|
|
662
|
+
collectors.
|
|
663
|
+
|
|
664
|
+
For items with pre-computed teardown_infos (from parallel workers),
|
|
665
|
+
only reporting happens here. For items without (setuponly mode),
|
|
666
|
+
finalizers from per_item_fixture_fins are executed sequentially.
|
|
667
|
+
"""
|
|
634
668
|
session = self._session
|
|
635
669
|
|
|
636
670
|
for item in items:
|
|
637
|
-
|
|
671
|
+
if item in teardown_infos:
|
|
672
|
+
# Teardown already ran in the worker — just report.
|
|
673
|
+
teardown_info = teardown_infos[item]
|
|
674
|
+
else:
|
|
675
|
+
# Fallback: run finalizers now (setuponly mode).
|
|
676
|
+
fins = per_item_fixture_fins.get(item, [])
|
|
677
|
+
|
|
678
|
+
def _run_fins(fns=fins):
|
|
679
|
+
exceptions = []
|
|
680
|
+
for fn in reversed(fns):
|
|
681
|
+
try:
|
|
682
|
+
fn()
|
|
683
|
+
except BaseException as e:
|
|
684
|
+
exceptions.append(e)
|
|
685
|
+
if len(exceptions) == 1:
|
|
686
|
+
raise exceptions[0]
|
|
687
|
+
if exceptions:
|
|
688
|
+
raise BaseExceptionGroup("errors during fixture teardown", exceptions)
|
|
689
|
+
|
|
690
|
+
teardown_info = CallInfo.from_call(_run_fins, when="teardown")
|
|
638
691
|
|
|
639
|
-
def _run_fins(fns=fins):
|
|
640
|
-
exceptions = []
|
|
641
|
-
for fn in reversed(fns):
|
|
642
|
-
try:
|
|
643
|
-
fn()
|
|
644
|
-
except BaseException as e:
|
|
645
|
-
exceptions.append(e)
|
|
646
|
-
if len(exceptions) == 1:
|
|
647
|
-
raise exceptions[0]
|
|
648
|
-
if exceptions:
|
|
649
|
-
raise BaseExceptionGroup("errors during fixture teardown", exceptions)
|
|
650
|
-
|
|
651
|
-
teardown_info = CallInfo.from_call(_run_fins, when="teardown")
|
|
652
692
|
rep = item.ihook.pytest_runtest_makereport(item=item, call=teardown_info)
|
|
653
693
|
item.ihook.pytest_runtest_logreport(report=rep)
|
|
654
694
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# file generated by setuptools-scm
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"__version__",
|
|
6
|
+
"__version_tuple__",
|
|
7
|
+
"version",
|
|
8
|
+
"version_tuple",
|
|
9
|
+
"__commit_id__",
|
|
10
|
+
"commit_id",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
TYPE_CHECKING = False
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from typing import Tuple
|
|
16
|
+
from typing import Union
|
|
17
|
+
|
|
18
|
+
VERSION_TUPLE = Tuple[Union[int, str], ...]
|
|
19
|
+
COMMIT_ID = Union[str, None]
|
|
20
|
+
else:
|
|
21
|
+
VERSION_TUPLE = object
|
|
22
|
+
COMMIT_ID = object
|
|
23
|
+
|
|
24
|
+
version: str
|
|
25
|
+
__version__: str
|
|
26
|
+
__version_tuple__: VERSION_TUPLE
|
|
27
|
+
version_tuple: VERSION_TUPLE
|
|
28
|
+
commit_id: COMMIT_ID
|
|
29
|
+
__commit_id__: COMMIT_ID
|
|
30
|
+
|
|
31
|
+
__version__ = version = '0.3.3'
|
|
32
|
+
__version_tuple__ = version_tuple = (0, 3, 3)
|
|
33
|
+
|
|
34
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Verify collected count matches actual test count (not inflated by cloning)."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@pytest.mark.parallelizable("children")
|
|
9
|
+
class TestExactCount:
|
|
10
|
+
barrier = threading.Barrier(3, timeout=10)
|
|
11
|
+
|
|
12
|
+
def test_a(self):
|
|
13
|
+
self.barrier.wait()
|
|
14
|
+
|
|
15
|
+
def test_b(self):
|
|
16
|
+
self.barrier.wait()
|
|
17
|
+
|
|
18
|
+
def test_c(self):
|
|
19
|
+
self.barrier.wait()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_standalone():
|
|
23
|
+
assert True
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Teardown runs even when the test call fails."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestState:
|
|
10
|
+
teardown_log: ClassVar[list] = []
|
|
11
|
+
lock: ClassVar[threading.Lock] = threading.Lock()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def resource_with_cleanup(request):
|
|
16
|
+
name = request.node.name
|
|
17
|
+
yield name
|
|
18
|
+
with TestState.lock:
|
|
19
|
+
TestState.teardown_log.append(name)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.mark.parallelizable("children")
|
|
23
|
+
class TestTeardownAfterFailure:
|
|
24
|
+
def test_pass(self, resource_with_cleanup):
|
|
25
|
+
assert True
|
|
26
|
+
|
|
27
|
+
def test_fail(self, resource_with_cleanup):
|
|
28
|
+
pytest.fail("intentional failure")
|
|
29
|
+
|
|
30
|
+
def test_raise(self, resource_with_cleanup):
|
|
31
|
+
raise RuntimeError("intentional")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_verify():
|
|
35
|
+
expected = {"test_pass", "test_fail", "test_raise"}
|
|
36
|
+
assert expected == set(TestState.teardown_log), (
|
|
37
|
+
f"teardown must run for all tests including failures: {TestState.teardown_log}"
|
|
38
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Verify function-scoped fixture teardown runs in parallel, not sequentially."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from typing import ClassVar
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TestState:
|
|
11
|
+
teardown_end_times: ClassVar[list] = []
|
|
12
|
+
call_end_times: ClassVar[list] = []
|
|
13
|
+
lock: ClassVar[threading.Lock] = threading.Lock()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def slow_teardown_resource():
|
|
18
|
+
yield "value"
|
|
19
|
+
# Each teardown takes 0.2s; if sequential that's 0.6s total
|
|
20
|
+
time.sleep(0.2)
|
|
21
|
+
with TestState.lock:
|
|
22
|
+
TestState.teardown_end_times.append(time.monotonic())
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.parallelizable("children")
|
|
26
|
+
class TestParallelTeardown:
|
|
27
|
+
barrier = threading.Barrier(3, timeout=10)
|
|
28
|
+
|
|
29
|
+
def _record_call(self):
|
|
30
|
+
with TestState.lock:
|
|
31
|
+
TestState.call_end_times.append(time.monotonic())
|
|
32
|
+
|
|
33
|
+
def test_a(self, slow_teardown_resource):
|
|
34
|
+
self.barrier.wait()
|
|
35
|
+
self._record_call()
|
|
36
|
+
|
|
37
|
+
def test_b(self, slow_teardown_resource):
|
|
38
|
+
self.barrier.wait()
|
|
39
|
+
self._record_call()
|
|
40
|
+
|
|
41
|
+
def test_c(self, slow_teardown_resource):
|
|
42
|
+
self.barrier.wait()
|
|
43
|
+
self._record_call()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_verify():
|
|
47
|
+
assert len(TestState.teardown_end_times) == 3
|
|
48
|
+
assert len(TestState.call_end_times) == 3
|
|
49
|
+
# Measure from when tests finished to when all teardowns completed
|
|
50
|
+
teardown_duration = max(TestState.teardown_end_times) - max(TestState.call_end_times)
|
|
51
|
+
# 3 x 0.2s sequential = 0.6s; parallel should complete in ~0.2s + overhead
|
|
52
|
+
assert teardown_duration < 0.45, (
|
|
53
|
+
f"Teardown appears sequential: {teardown_duration:.2f}s "
|
|
54
|
+
f"(3 x 0.2s = 0.6s sequential, expected < 0.45s parallel)"
|
|
55
|
+
)
|