pytest-threadpool 0.2.0__tar.gz → 0.3.0__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.
Files changed (103) hide show
  1. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/ANALYSIS.md +1 -1
  2. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/CONTRIBUTING.md +1 -1
  3. pytest_threadpool-0.2.0/README.md → pytest_threadpool-0.3.0/PKG-INFO +41 -4
  4. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/PLAN.md +2 -1
  5. pytest_threadpool-0.2.0/PKG-INFO → pytest_threadpool-0.3.0/README.md +23 -21
  6. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/TODO.md +0 -12
  7. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/pyproject.toml +3 -2
  8. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/src/pytest_threadpool/_fixtures.py +68 -0
  9. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/src/pytest_threadpool/_runner.py +160 -43
  10. pytest_threadpool-0.3.0/tests/integration_tests/cases/fixture_func_addfinalizer.py +49 -0
  11. pytest_threadpool-0.3.0/tests/integration_tests/cases/fixture_func_chain.py +54 -0
  12. pytest_threadpool-0.3.0/tests/integration_tests/cases/fixture_func_multiple_per_test.py +65 -0
  13. pytest_threadpool-0.3.0/tests/integration_tests/cases/fixture_func_setup_parallel.py +51 -0
  14. pytest_threadpool-0.3.0/tests/integration_tests/cases/fixture_func_with_shared_deps.py +61 -0
  15. pytest_threadpool-0.3.0/tests/integration_tests/cases/fixture_func_with_tmp_path.py +45 -0
  16. pytest_threadpool-0.3.0/tests/integration_tests/cases/fixture_func_with_xunit.py +55 -0
  17. pytest_threadpool-0.3.0/tests/integration_tests/cases/fixture_func_yield_teardown.py +47 -0
  18. pytest_threadpool-0.3.0/tests/integration_tests/cases/xunit_method_teardown_runs.py +43 -0
  19. pytest_threadpool-0.3.0/tests/integration_tests/test_pytester_func_fixtures.py +69 -0
  20. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/.claude/settings.local.json +0 -0
  21. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/.github/workflows/ci.yml +0 -0
  22. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/.gitignore +0 -0
  23. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/CLAUDE.md +0 -0
  24. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/LICENSE +0 -0
  25. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/hooks/pre-commit +0 -0
  26. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/scripts/setup-dev +0 -0
  27. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/src/pytest_threadpool/__init__.py +0 -0
  28. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/src/pytest_threadpool/_api.py +0 -0
  29. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/src/pytest_threadpool/_constants.py +0 -0
  30. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/src/pytest_threadpool/_grouping.py +0 -0
  31. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/src/pytest_threadpool/_markers.py +0 -0
  32. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/src/pytest_threadpool/plugin.py +0 -0
  33. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/__init__.py +0 -0
  34. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/__init__.py +0 -0
  35. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/__init__.py +0 -0
  36. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/_templates.py +0 -0
  37. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/class_barrier_concurrency.py +0 -0
  38. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/class_single_method.py +0 -0
  39. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/class_thread_verification.py +0 -0
  40. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/edge_cross_module_group.py +0 -0
  41. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/edge_keyboard_interrupt.py +0 -0
  42. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/edge_nested_threads.py +0 -0
  43. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/edge_sigint.py +0 -0
  44. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/edge_sigint_many.py +0 -0
  45. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/edge_system_exit.py +0 -0
  46. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_autouse_function.py +0 -0
  47. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_class_scoped_once.py +0 -0
  48. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_class_yield.py +0 -0
  49. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_function_scoped.py +0 -0
  50. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_interdependent_finalizers.py +0 -0
  51. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_multiple_scopes.py +0 -0
  52. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_parameterized.py +0 -0
  53. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_teardown_exception.py +0 -0
  54. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/fixture_yield_cleanup.py +0 -0
  55. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/marks_custom.py +0 -0
  56. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/marks_standard.py +0 -0
  57. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/parallel_only_skip.py +0 -0
  58. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/reporting_cross_module.py +0 -0
  59. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/reporting_incremental.py +0 -0
  60. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/reporting_incremental_conftest.py +0 -0
  61. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/reporting_package_children.py +0 -0
  62. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_all_merged.py +0 -0
  63. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_all_not_parallelizable.py +0 -0
  64. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_children_separate_params.py +0 -0
  65. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_dynamic_parametrize.py +0 -0
  66. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_method_overrides_class.py +0 -0
  67. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_module_children.py +0 -0
  68. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_not_parallelizable_function.py +0 -0
  69. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_not_parallelizable_method.py +0 -0
  70. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/scope_parameters.py +0 -0
  71. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/sequential_bare_functions.py +0 -0
  72. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/sequential_unmarked_class.py +0 -0
  73. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/setup_all_fail.py +0 -0
  74. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/setup_mixed_pass_fail.py +0 -0
  75. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/shared_counter.py +0 -0
  76. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/shared_cross_group_non_pickleable.py +0 -0
  77. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/shared_dict_mutation.py +0 -0
  78. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/shared_non_pickleable.py +0 -0
  79. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/shared_two_phase_barrier.py +0 -0
  80. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/validate_threadpool.py +0 -0
  81. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/validate_threadpool_conftest.py +0 -0
  82. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/xunit_class_setup.py +0 -0
  83. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/xunit_combined_setup.py +0 -0
  84. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/xunit_function_setup.py +0 -0
  85. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/xunit_method_setup.py +0 -0
  86. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/cases/xunit_module_setup.py +0 -0
  87. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/conftest.py +0 -0
  88. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_class.py +0 -0
  89. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_edge_cases.py +0 -0
  90. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_fixtures.py +0 -0
  91. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_marks.py +0 -0
  92. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_package.py +0 -0
  93. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_reporting.py +0 -0
  94. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_scopes.py +0 -0
  95. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_sequential.py +0 -0
  96. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_shared.py +0 -0
  97. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/integration_tests/test_pytester_xunit.py +0 -0
  98. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/unit_tests/__init__.py +0 -0
  99. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/unit_tests/test_unit_api.py +0 -0
  100. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/unit_tests/test_unit_fixtures.py +0 -0
  101. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/unit_tests/test_unit_grouping.py +0 -0
  102. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/unit_tests/test_unit_markers.py +0 -0
  103. {pytest_threadpool-0.2.0 → pytest_threadpool-0.3.0}/tests/unit_tests/test_unit_plugin.py +0 -0
@@ -2,7 +2,7 @@
2
2
 
3
3
  ## Overview
4
4
 
5
- pytest-threadpool runs test bodies in parallel via `ThreadPoolExecutor` while keeping all pytest internals (fixtures, setup/teardown, reporting) sequential. This analysis covers risks, potential bugs, and missing test coverage.
5
+ pytest-threadpool runs test bodies and function-scoped fixture setup in parallel via a thread pool while keeping shared fixtures (module/class/session scope) and teardown sequential. Function-scoped FixtureDefs are cloned per-item so each worker creates independent fixture instances. This analysis covers risks, potential bugs, and missing test coverage.
6
6
 
7
7
  ---
8
8
 
@@ -107,7 +107,7 @@ src/pytest_threadpool/
107
107
  _constants.py # ParallelScope and _GroupPrefix enums
108
108
  _markers.py # MarkerResolver: marker introspection
109
109
  _grouping.py # GroupKeyBuilder: parallel batch grouping
110
- _fixtures.py # FixtureManager: finalizer save/restore
110
+ _fixtures.py # FixtureManager: fixture cloning, shared cache population, finalizer save/restore
111
111
  _runner.py # ParallelRunner: parallel execution orchestration
112
112
  plugin.py # pytest hook implementations (wiring only)
113
113
 
@@ -1,7 +1,25 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytest-threadpool
3
+ Version: 0.3.0
4
+ Summary: Parallel test execution for free-threaded Python builds
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Keywords: free-threaded,nogil,parallel,pytest,pytest parallel,testing,threadpool
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Framework :: Pytest
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development :: Testing
14
+ Requires-Python: >=3.13
15
+ Requires-Dist: pytest>=9.0.2
16
+ Requires-Dist: selenium>=4.41.0
17
+ Description-Content-Type: text/markdown
18
+
1
19
  # pytest-threadpool
2
20
 
3
- [![PyPI](https://img.shields.io/pypi/v/pytest-threadpool.svg)](https://pypi.org/project/pytest-threadpool/)
4
- [![Python](https://img.shields.io/pypi/pyversions/pytest-threadpool.svg)](https://pypi.org/project/pytest-threadpool/)
21
+ ![PyPI - Version](https://img.shields.io/pypi/v/pytest-threadpool)
22
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-threadpool)
5
23
  [![License](https://img.shields.io/github/license/pytest-threadpool/pytest-threadpool)](https://github.com/pytest-threadpool/pytest-threadpool/blob/main/LICENSE)
6
24
  [![GitHub](https://img.shields.io/badge/GitHub-pytest--threadpool-blue?logo=github)](https://github.com/pytest-threadpool/pytest-threadpool)
7
25
  [![CI](https://github.com/pytest-threadpool/pytest-threadpool/actions/workflows/ci.yml/badge.svg)](https://github.com/pytest-threadpool/pytest-threadpool/actions/workflows/ci.yml)
@@ -9,8 +27,9 @@
9
27
 
10
28
  **Status: Beta** · Parallel test execution for free-threaded Python builds (3.13t+).
11
29
 
12
- Runs test *bodies* concurrently in a `ThreadPoolExecutor` while keeping
13
- fixture setup/teardown sequential (pytest internals are not thread-safe).
30
+ Runs test *bodies* and function-scoped fixture setup concurrently in a thread
31
+ pool while keeping shared fixtures (module/class/session scope) and teardown
32
+ sequential.
14
33
 
15
34
  ## Installation
16
35
 
@@ -58,6 +77,24 @@ def test_must_be_sequential(): ...
58
77
  | `parameters` | Parametrized variants of the same test run concurrently |
59
78
  | `all` | Combines `children` + `parameters` |
60
79
 
80
+ ## Fixture handling
81
+
82
+ Function-scoped fixtures are cloned per-item and set up in parallel alongside
83
+ test calls. Each worker gets independent fixture instances — no shared mutable
84
+ state between concurrent fixture setups.
85
+
86
+ Shared fixtures (module, class, and session scope) are resolved once
87
+ sequentially before workers launch and served from cache to all items.
88
+
89
+ | Scope | Behavior |
90
+ |------------|-------------------------------------------------|
91
+ | `function` | Cloned per-item, set up in parallel workers |
92
+ | `class` | Resolved once, cached, shared across items |
93
+ | `module` | Resolved once, cached, shared across items |
94
+ | `session` | Resolved once, cached, shared across items |
95
+
96
+ Teardown for all scopes runs sequentially after the parallel group completes.
97
+
61
98
  ## Marker levels
62
99
 
63
100
  Markers can be applied at function, class, module (`pytestmark`), or
@@ -46,7 +46,8 @@ This is probably the single highest-value documentation addition.
46
46
  You want one section that explicitly says:
47
47
  Guarantees
48
48
  test call phases may run concurrently
49
- fixture setup/teardown remains sequential
49
+ function-scoped fixture setup runs in parallel (cloned per-item)
50
+ shared fixture setup/teardown (module/class/session) remains sequential
50
51
  unmarked tests remain sequential
51
52
  not_parallelizable always wins
52
53
  marker precedence rules are stable
@@ -1,24 +1,7 @@
1
- Metadata-Version: 2.4
2
- Name: pytest-threadpool
3
- Version: 0.2.0
4
- Summary: Parallel test execution for free-threaded Python builds
5
- License-Expression: MIT
6
- License-File: LICENSE
7
- Keywords: free-threaded,nogil,parallel,pytest,testing,threadpool
8
- Classifier: Development Status :: 4 - Beta
9
- Classifier: Framework :: Pytest
10
- Classifier: Intended Audience :: Developers
11
- Classifier: License :: OSI Approved :: MIT License
12
- Classifier: Programming Language :: Python :: 3
13
- Classifier: Topic :: Software Development :: Testing
14
- Requires-Python: >=3.13
15
- Requires-Dist: pytest>=9.0.2
16
- Description-Content-Type: text/markdown
17
-
18
1
  # pytest-threadpool
19
2
 
20
- [![PyPI](https://img.shields.io/pypi/v/pytest-threadpool.svg)](https://pypi.org/project/pytest-threadpool/)
21
- [![Python](https://img.shields.io/pypi/pyversions/pytest-threadpool.svg)](https://pypi.org/project/pytest-threadpool/)
3
+ ![PyPI - Version](https://img.shields.io/pypi/v/pytest-threadpool)
4
+ ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/pytest-threadpool)
22
5
  [![License](https://img.shields.io/github/license/pytest-threadpool/pytest-threadpool)](https://github.com/pytest-threadpool/pytest-threadpool/blob/main/LICENSE)
23
6
  [![GitHub](https://img.shields.io/badge/GitHub-pytest--threadpool-blue?logo=github)](https://github.com/pytest-threadpool/pytest-threadpool)
24
7
  [![CI](https://github.com/pytest-threadpool/pytest-threadpool/actions/workflows/ci.yml/badge.svg)](https://github.com/pytest-threadpool/pytest-threadpool/actions/workflows/ci.yml)
@@ -26,8 +9,9 @@ Description-Content-Type: text/markdown
26
9
 
27
10
  **Status: Beta** · Parallel test execution for free-threaded Python builds (3.13t+).
28
11
 
29
- Runs test *bodies* concurrently in a `ThreadPoolExecutor` while keeping
30
- fixture setup/teardown sequential (pytest internals are not thread-safe).
12
+ Runs test *bodies* and function-scoped fixture setup concurrently in a thread
13
+ pool while keeping shared fixtures (module/class/session scope) and teardown
14
+ sequential.
31
15
 
32
16
  ## Installation
33
17
 
@@ -75,6 +59,24 @@ def test_must_be_sequential(): ...
75
59
  | `parameters` | Parametrized variants of the same test run concurrently |
76
60
  | `all` | Combines `children` + `parameters` |
77
61
 
62
+ ## Fixture handling
63
+
64
+ Function-scoped fixtures are cloned per-item and set up in parallel alongside
65
+ test calls. Each worker gets independent fixture instances — no shared mutable
66
+ state between concurrent fixture setups.
67
+
68
+ Shared fixtures (module, class, and session scope) are resolved once
69
+ sequentially before workers launch and served from cache to all items.
70
+
71
+ | Scope | Behavior |
72
+ |------------|-------------------------------------------------|
73
+ | `function` | Cloned per-item, set up 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
+
78
+ Teardown for all scopes runs sequentially after the parallel group completes.
79
+
78
80
  ## Marker levels
79
81
 
80
82
  Markers can be applied at function, class, module (`pytestmark`), or
@@ -1,17 +1,5 @@
1
1
  # TODO — Road to Beta and Beyond
2
2
 
3
- ## Beta Requirements
4
-
5
- ### CI/CD
6
- - [x] Add GitHub Actions running tests on Python 3.13t
7
- - [x] Validate at least one pytest version in CI
8
-
9
- ### Documentation
10
- - [x] Add "Known Limitations" section to README (private API fragility, no plugin compat guarantees, no captured stdout in parallel)
11
- - [x] Add pytest version upper bound or document tested versions
12
-
13
- ---
14
-
15
3
  ## 1.0 Requirements
16
4
 
17
5
  ### Phase 1 — Trust (Highest ROI)
@@ -1,11 +1,11 @@
1
1
  [project]
2
2
  name = "pytest-threadpool"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Parallel test execution for free-threaded Python builds"
5
5
  requires-python = ">=3.13"
6
6
  license = "MIT"
7
7
  readme = "README.md"
8
- keywords = ["pytest", "parallel", "free-threaded", "nogil", "testing", "threadpool"]
8
+ keywords = ["pytest", "parallel", "free-threaded", "nogil", "testing", "threadpool", "pytest parallel"]
9
9
  classifiers = [
10
10
  "Framework :: Pytest",
11
11
  "Development Status :: 4 - Beta",
@@ -16,6 +16,7 @@ classifiers = [
16
16
  ]
17
17
  dependencies = [
18
18
  "pytest>=9.0.2",
19
+ "selenium>=4.41.0",
19
20
  ]
20
21
 
21
22
  [project.entry-points.pytest11]
@@ -1,5 +1,6 @@
1
1
  """Fixture finalizer save/restore helpers for parallel execution."""
2
2
 
3
+ from _pytest.fixtures import FixtureDef
3
4
  from _pytest.scope import Scope
4
5
 
5
6
 
@@ -12,6 +13,40 @@ class FixtureManager:
12
13
  runner.py and fixtures.py use.
13
14
  """
14
15
 
16
+ @staticmethod
17
+ def clone_function_fixturedefs(item) -> None:
18
+ """Clone function-scoped FixtureDefs so this item has its own copies.
19
+
20
+ Shared (module/class/session) FixtureDefs are kept as-is — their
21
+ cached values are read-only after the first item's setup.
22
+ Function-scoped FixtureDefs get independent copies with fresh
23
+ cached_result and _finalizers, allowing concurrent fixture setup
24
+ across items without racing on shared singleton state.
25
+ """
26
+ # noinspection PyProtectedMember
27
+ # request._arg2fixturedefs, fixturedef._scope: no public API;
28
+ # mirrors pytest's own TopRequest/FixtureDef internals.
29
+ request = getattr(item, "_request", None)
30
+ if not request or not hasattr(request, "_arg2fixturedefs"):
31
+ return
32
+
33
+ new_arg2fds = {}
34
+ for argname, fds in request._arg2fixturedefs.items(): # pyright: ignore[reportPrivateUsage]
35
+ new_fds = []
36
+ for fd in fds:
37
+ if fd._scope is Scope.Function: # pyright: ignore[reportPrivateUsage]
38
+ clone = FixtureDef.__new__(FixtureDef)
39
+ clone.__dict__.update(fd.__dict__)
40
+ clone.cached_result = None
41
+ clone._finalizers = [] # pyright: ignore[reportPrivateUsage, reportAttributeAccessIssue]
42
+ new_fds.append(clone)
43
+ else:
44
+ new_fds.append(fd)
45
+ new_arg2fds[argname] = new_fds
46
+
47
+ # _arg2fixturedefs is typed Final but not enforced at runtime
48
+ object.__setattr__(request, "_arg2fixturedefs", new_arg2fds)
49
+
15
50
  @staticmethod
16
51
  def save_and_clear_function_fixtures(item) -> list:
17
52
  """After an item's setup, save its function-scoped fixture finalizers
@@ -58,6 +93,39 @@ class FixtureManager:
58
93
  if fixturedef._scope is Scope.Function and fixturedef.cached_result is not None: # pyright: ignore[reportPrivateUsage]
59
94
  fixturedef.cached_result = None
60
95
 
96
+ @staticmethod
97
+ def populate_shared_fixtures(item) -> None:
98
+ """Resolve only non-function-scoped fixtures for the item.
99
+
100
+ Populates shared fixture caches (module/class/session scope) by
101
+ calling getfixturevalue for each non-function-scoped fixture.
102
+ Function-scoped fixtures are skipped — they will be created later
103
+ from cloned FixtureDefs in parallel workers.
104
+
105
+ Must be called inside a setup hook context (item in setupstate)
106
+ so that addfinalizer works for the resolved fixtures.
107
+ """
108
+ # noinspection PyProtectedMember
109
+ # request._arg2fixturedefs, fixturedef._scope: no public API;
110
+ # mirrors pytest's own TopRequest/FixtureDef internals.
111
+ request = getattr(item, "_request", None)
112
+ if not request or not hasattr(request, "_arg2fixturedefs"):
113
+ return
114
+
115
+ for argname in item.fixturenames:
116
+ if argname in item.funcargs:
117
+ continue
118
+ fds = request._arg2fixturedefs.get(argname, []) # pyright: ignore[reportPrivateUsage]
119
+ if fds and fds[-1]._scope is not Scope.Function: # pyright: ignore[reportPrivateUsage]
120
+ value = request.getfixturevalue(argname)
121
+ item.funcargs[argname] = value
122
+ # Eagerly initialize lazy state on shared fixture values.
123
+ # TmpPathFactory.getbasetemp() lazily creates the basetemp
124
+ # directory; without this, parallel workers would race on
125
+ # the first tmp_path_factory.mktemp() call.
126
+ if hasattr(value, "getbasetemp"):
127
+ value.getbasetemp()
128
+
61
129
  @staticmethod
62
130
  def save_collector_finalizers(session, next_item) -> list:
63
131
  """Save finalizers from stack nodes that would be torn down when
@@ -339,12 +339,17 @@ class ParallelRunner:
339
339
  ihook.pytest_runtest_logfinish(nodeid=item.nodeid, location=item.location)
340
340
 
341
341
  def _run_parallel(self, items) -> None:
342
- """Run a group's tests with parallel call phases.
342
+ """Run a group's tests with parallel fixture setup and calls.
343
343
 
344
- 1. Sequential: setup every item (no reporting yet).
345
- 2. Parallel: item.runtest() in a thread pool.
346
- 3. Sequential: report setup and call results per item.
347
- 4. Sequential: teardown.
344
+ Function-scoped FixtureDefs are cloned per-item so their setup can
345
+ run concurrently alongside the test call. Shared fixtures
346
+ (module/class/session scope) are set up once via the first item,
347
+ then served from cache to all workers.
348
+
349
+ 1. Sequential: set up first item (populates shared fixture caches).
350
+ 2. Sequential: prepare remaining items (_initrequest + clone FixtureDefs).
351
+ 3. Parallel: workers run setup (cloned fixtures) + call per item.
352
+ 4. Sequential: report, teardown.
348
353
  """
349
354
  session = self._session
350
355
  setup_passed = {}
@@ -352,12 +357,12 @@ class ParallelRunner:
352
357
  per_item_fixture_fins = {}
353
358
  saved_collector_fins = []
354
359
 
355
- # Phase 1: sequential setup (silent)
356
360
  # noinspection PyProtectedMember
357
361
  # item._request/_initrequest and session._setupstate: no public API
358
362
  # for request lifecycle or setup state management. Mirrors pytest's
359
363
  # own runner.py (_pytest.runner.runtestprotocol / SetupState).
360
- for item in items:
364
+ def _setup_first_item(item):
365
+ """Full sequential setup for the first item (caches shared fixtures)."""
361
366
  if hasattr(item, "_request") and not item._request: # pyright: ignore[reportPrivateUsage]
362
367
  item._initrequest() # pyright: ignore[reportPrivateUsage]
363
368
 
@@ -381,6 +386,8 @@ class ParallelRunner:
381
386
  session._setupstate.stack.pop(item) # pyright: ignore[reportPrivateUsage]
382
387
 
383
388
  if session.config.getoption("setuponly", False):
389
+ for item in items:
390
+ _setup_first_item(item)
384
391
  for item in items:
385
392
  ihook = item.ihook
386
393
  ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
@@ -390,29 +397,123 @@ class ParallelRunner:
390
397
  self._teardown_all(items, per_item_fixture_fins, saved_collector_fins)
391
398
  return
392
399
 
393
- # Phase 2: parallel calls with live reporting
394
- callable_items = [it for it in items if setup_passed.get(it)]
395
- workers = min(self._nthreads, len(callable_items)) if callable_items else 1
400
+ # Phase 1+2: fire hooks for all items, populate shared fixture caches,
401
+ # and clone function-scoped FixtureDefs.
402
+ #
403
+ # The first item that passes hook evaluation resolves shared fixtures
404
+ # (module/class/session scope) to populate FixtureDef caches. All
405
+ # subsequent items get cache hits for shared fixtures. Function-scoped
406
+ # fixtures are NOT created here — they are deferred to parallel workers.
407
+ #
408
+ # noinspection PyProtectedMember
409
+ parallel_items = []
410
+ shared_populated = False
411
+
412
+ for item in items:
413
+ if hasattr(item, "_request") and not item._request: # pyright: ignore[reportPrivateUsage]
414
+ item._initrequest() # pyright: ignore[reportPrivateUsage]
415
+
416
+ # Handle collector transitions (needed for PKG_CHILDREN groups
417
+ # that span multiple modules).
418
+ needed = set(item.listchain())
419
+ if any(node not in needed for node in session._setupstate.stack): # pyright: ignore[reportPrivateUsage]
420
+ saved_collector_fins.extend(
421
+ FixtureManager.save_collector_finalizers(session, item)
422
+ )
423
+ session._setupstate.teardown_exact(nextitem=item) # pyright: ignore[reportPrivateUsage]
424
+
425
+ # Fire setup hooks with a custom setup function:
426
+ # - First eligible item: resolve shared fixtures only (populates caches)
427
+ # - Remaining items: no-op (shared caches already populated)
428
+ # This evaluates skip/xfail markers and initializes capture/logging
429
+ # without creating function-scoped fixtures.
430
+ original_setup = item.setup
431
+ if not shared_populated:
432
+ item.setup = lambda i=item: FixtureManager.populate_shared_fixtures(i)
433
+ else:
434
+ item.setup = lambda: None
435
+ try:
436
+ hook_rep = call_and_report(item, "setup", log=False)
437
+ finally:
438
+ item.setup = original_setup
439
+
440
+ # Pop item from setupstate (pushed by _setupstate.setup inside the
441
+ # hook) so the next item's hook doesn't fail the stack assertion.
442
+ if item in session._setupstate.stack: # pyright: ignore[reportPrivateUsage]
443
+ session._setupstate.stack.pop(item) # pyright: ignore[reportPrivateUsage]
396
444
 
445
+ if not hook_rep.passed:
446
+ # Hooks raised (skip, skipif, xfail NOTRUN, etc.)
447
+ setup_reports[item] = hook_rep
448
+ setup_passed[item] = False
449
+ continue
450
+
451
+ if not shared_populated:
452
+ shared_populated = True
453
+
454
+ FixtureManager.clone_function_fixturedefs(item)
455
+ parallel_items.append(item)
456
+
457
+ # Push all parallel items to setupstate stack so addfinalizer
458
+ # assertions pass during fixture resolution in worker threads.
459
+ # Done after the hook loop so items don't interfere with each
460
+ # other's _setupstate.setup() assertions.
461
+ for item in parallel_items:
462
+ session._setupstate.stack[item] = ([item.teardown], None) # pyright: ignore[reportPrivateUsage]
463
+
464
+ # Phase 3: parallel setup + call
465
+ workers = min(self._nthreads, len(parallel_items) or 1)
397
466
  call_results = {}
398
467
  reported = set()
399
468
  live = _LiveReporter(session, items)
400
469
  live.pre_print()
401
470
 
402
- def _do_call(test_item):
471
+ cancelled = threading.Event()
472
+
473
+ def _do_setup_and_call(test_item):
474
+ """Setup + call worker (for items with cloned FixtureDefs).
475
+
476
+ Fixture setup uses cloned function-scoped FixtureDefs so each
477
+ worker creates independent fixture instances without racing
478
+ on shared FixtureDef state. Shared fixtures are already cached
479
+ from the first item's sequential setup, so their FixtureDef.execute()
480
+ is a cache-hit read.
481
+
482
+ addfinalizer is redirected to a per-item list to avoid the
483
+ setupstate stack assertion (the item IS in the stack, but
484
+ list.append on the stack entry is also safe on free-threaded Python;
485
+ the redirect simply avoids coupling to setupstate internals).
486
+ """
403
487
  if cancelled.is_set():
404
- return test_item, CallInfo.from_call(lambda: None, when="call")
405
- live.mark_running(test_item)
406
- call_info = CallInfo.from_call(lambda: test_item.runtest(), when="call")
407
- if not cancelled.is_set():
408
- live.mark_call_done(test_item, call_info.excinfo)
409
- return test_item, call_info
488
+ return test_item, None, None, []
489
+
490
+ # Redirect node.addfinalizer to per-item list
491
+ original_addfinalizer = test_item.addfinalizer
492
+ node_fins = []
493
+ test_item.addfinalizer = lambda fin: node_fins.append(fin)
494
+
495
+ try:
496
+ setup_info = CallInfo.from_call(lambda: test_item.setup(), when="setup")
497
+ finally:
498
+ test_item.addfinalizer = original_addfinalizer
499
+
500
+ if setup_info.excinfo is None:
501
+ fixture_fins = FixtureManager.save_and_clear_function_fixtures(test_item)
502
+ if not cancelled.is_set():
503
+ live.mark_running(test_item)
504
+ call_info = CallInfo.from_call(lambda: test_item.runtest(), when="call")
505
+ if not cancelled.is_set():
506
+ live.mark_call_done(test_item, call_info.excinfo)
507
+ return test_item, setup_info, call_info, fixture_fins
508
+ return test_item, setup_info, None, fixture_fins
509
+
510
+ FixtureManager.clear_function_fixture_caches(test_item)
511
+ return test_item, setup_info, None, []
410
512
 
411
513
  def _report_item(item):
412
514
  """Report a single item — live cursor mode or plain fallback."""
413
515
  ihook = item.ihook
414
516
 
415
- # Build the call report first (needed for a live dot letter)
416
517
  call_rep = None
417
518
  if setup_passed[item] and item in call_results:
418
519
  call_info = call_results[item]
@@ -420,10 +521,6 @@ class ParallelRunner:
420
521
 
421
522
  report = call_rep or setup_reports[item]
422
523
 
423
- # Suppress terminal reporter output, fire hooks for stats only.
424
- # try/finally ensures restore() runs even on KeyboardInterrupt,
425
- # otherwise the terminal writer stays suppressed and pytest's
426
- # interrupt traceback is silently lost.
427
524
  live.suppress()
428
525
  try:
429
526
  ihook.pytest_runtest_logstart(nodeid=item.nodeid, location=item.location)
@@ -440,8 +537,7 @@ class ParallelRunner:
440
537
  reported.add(item)
441
538
 
442
539
  interrupted = False
443
- cancelled = threading.Event()
444
- if workers > 1 and len(callable_items) > 1:
540
+ if workers > 1 and len(parallel_items) > 1:
445
541
  work_queue = queue.SimpleQueue()
446
542
  result_queue = queue.SimpleQueue()
447
543
 
@@ -451,16 +547,21 @@ class ParallelRunner:
451
547
  if work_item is None or cancelled.is_set():
452
548
  return
453
549
  try:
454
- result = _do_call(work_item)
550
+ item, setup_info, call_info, fixture_fins = _do_setup_and_call(work_item)
551
+ per_item_fixture_fins[item] = fixture_fins
552
+ if setup_info is not None:
553
+ setup_rep = item.ihook.pytest_runtest_makereport(
554
+ item=item, call=setup_info
555
+ )
556
+ setup_reports[item] = setup_rep
557
+ setup_passed[item] = setup_info.excinfo is None
558
+ result_queue.put((item, call_info))
455
559
  except BaseException as exc:
456
- # Ensure the result queue always gets an entry so the
457
- # main thread never blocks forever on .get().
458
560
  call_info = CallInfo.from_call(
459
561
  lambda e=exc: (_ for _ in ()).throw(e),
460
562
  when="call",
461
563
  )
462
- result = (work_item, call_info)
463
- result_queue.put(result)
564
+ result_queue.put((work_item, call_info))
464
565
 
465
566
  threads = []
466
567
  for _ in range(workers):
@@ -468,36 +569,46 @@ class ParallelRunner:
468
569
  t.start()
469
570
  threads.append(t)
470
571
 
471
- for ci in callable_items:
472
- work_queue.put(ci)
572
+ # Submit all parallel items (setup + call)
573
+ for item in parallel_items:
574
+ work_queue.put(item)
473
575
 
474
576
  try:
475
- remaining = len(callable_items)
476
- while remaining > 0:
477
- item, call_info = result_queue.get()
478
- call_results[item] = call_info
479
- _report_item(item)
480
- remaining -= 1
577
+ collected = 0
578
+ while collected < len(parallel_items):
579
+ finished_item, call_info = result_queue.get()
580
+ if call_info is not None:
581
+ call_results[finished_item] = call_info
582
+ _report_item(finished_item)
583
+ collected += 1
481
584
  except KeyboardInterrupt:
482
585
  interrupted = True
483
586
  cancelled.set()
484
587
 
485
- # Signal workers to stop (best-effort; they're daemon threads)
486
588
  for _ in range(workers):
487
589
  work_queue.put(None)
488
590
  if not interrupted:
489
591
  for t in threads:
490
592
  t.join()
491
593
  else:
594
+ # Single worker fallback: sequential setup + call
492
595
  try:
493
- for item in callable_items:
494
- _, call_info = _do_call(item)
495
- call_results[item] = call_info
596
+ for item in parallel_items:
597
+ item, setup_info, call_info, fixture_fins = _do_setup_and_call(item)
598
+ per_item_fixture_fins[item] = fixture_fins
599
+ if setup_info is not None:
600
+ setup_rep = item.ihook.pytest_runtest_makereport(
601
+ item=item, call=setup_info
602
+ )
603
+ setup_reports[item] = setup_rep
604
+ setup_passed[item] = setup_info.excinfo is None
605
+ if call_info is not None:
606
+ call_results[item] = call_info
496
607
  _report_item(item)
497
608
  except KeyboardInterrupt:
498
609
  interrupted = True
499
610
 
500
- # Phase 3: report any remaining items (setup failures, stragglers)
611
+ # Report any remaining items (setup failures, stragglers)
501
612
  if not interrupted:
502
613
  for item in items:
503
614
  if item not in reported:
@@ -505,7 +616,13 @@ class ParallelRunner:
505
616
 
506
617
  live.finish()
507
618
 
508
- # Phase 4: teardown (always runs, even after interrupt)
619
+ # Pop parallel items from setupstate stack
620
+ # noinspection PyProtectedMember
621
+ for item in parallel_items:
622
+ if item in session._setupstate.stack: # pyright: ignore[reportPrivateUsage]
623
+ session._setupstate.stack.pop(item) # pyright: ignore[reportPrivateUsage]
624
+
625
+ # Teardown (always runs, even after interrupt)
509
626
  self._teardown_all(items, per_item_fixture_fins, saved_collector_fins)
510
627
 
511
628
  if interrupted:
@@ -0,0 +1,49 @@
1
+ """Function-scoped fixture using request.addfinalizer (non-yield style)."""
2
+
3
+ import threading
4
+ from typing import ClassVar
5
+
6
+ import pytest
7
+
8
+
9
+ class TestState:
10
+ setup_log: ClassVar[list] = []
11
+ finalizer_log: ClassVar[list] = []
12
+ lock: ClassVar[threading.Lock] = threading.Lock()
13
+
14
+
15
+ @pytest.fixture
16
+ def managed_resource(request):
17
+ name = request.node.name
18
+ with TestState.lock:
19
+ TestState.setup_log.append(name)
20
+
21
+ def cleanup():
22
+ with TestState.lock:
23
+ TestState.finalizer_log.append(name)
24
+
25
+ request.addfinalizer(cleanup) # noqa: PT021 — intentionally testing addfinalizer API
26
+ return f"resource_{name}"
27
+
28
+
29
+ @pytest.mark.parallelizable("children")
30
+ class TestAddfinalizer:
31
+ barrier = threading.Barrier(3, timeout=10)
32
+
33
+ def test_a(self, managed_resource):
34
+ assert managed_resource == "resource_test_a"
35
+ self.barrier.wait()
36
+
37
+ def test_b(self, managed_resource):
38
+ assert managed_resource == "resource_test_b"
39
+ self.barrier.wait()
40
+
41
+ def test_c(self, managed_resource):
42
+ assert managed_resource == "resource_test_c"
43
+ self.barrier.wait()
44
+
45
+
46
+ def test_verify():
47
+ expected = {"test_a", "test_b", "test_c"}
48
+ assert expected == set(TestState.setup_log), f"setups: {TestState.setup_log}"
49
+ assert expected == set(TestState.finalizer_log), f"finalizers: {TestState.finalizer_log}"