pytest-threadpool 0.3.3__tar.gz → 0.3.4__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.3 → pytest_threadpool-0.3.4}/.github/workflows/ci.yml +2 -1
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/CONTRIBUTING.md +3 -1
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/PKG-INFO +14 -4
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/README.md +10 -3
- pytest_threadpool-0.3.4/codecov.yml +11 -0
- pytest_threadpool-0.3.4/examples/__init__.py +3 -0
- pytest_threadpool-0.3.4/examples/test_di/__init__.py +1 -0
- pytest_threadpool-0.3.4/examples/test_di/conftest.py +28 -0
- pytest_threadpool-0.3.4/examples/test_di/container.py +44 -0
- pytest_threadpool-0.3.4/examples/test_di/providers.py +104 -0
- pytest_threadpool-0.3.4/examples/test_di/services.py +53 -0
- pytest_threadpool-0.3.4/examples/test_di/test_factory.py +19 -0
- pytest_threadpool-0.3.4/examples/test_di/test_local.py +99 -0
- pytest_threadpool-0.3.4/examples/test_di/test_singleton.py +20 -0
- pytest_threadpool-0.3.4/examples/test_di/test_thread_local.py +77 -0
- pytest_threadpool-0.3.4/examples/test_logging/test_file_logging.py +30 -0
- pytest_threadpool-0.3.4/examples/test_logging/test_log_capture.py +66 -0
- pytest_threadpool-0.3.4/examples/test_logging/test_print_capture.py +44 -0
- pytest_threadpool-0.3.4/examples/test_queue/conftest.py +16 -0
- pytest_threadpool-0.3.4/examples/test_queue/test_queue.py +30 -0
- pytest_threadpool-0.3.4/examples/test_queue/user_pool.py +26 -0
- pytest_threadpool-0.3.4/examples/test_shared_state/__init__.py +0 -0
- pytest_threadpool-0.3.4/examples/test_shared_state/test_barrier.py +47 -0
- pytest_threadpool-0.3.4/examples/test_shared_state/test_counter.py +43 -0
- pytest_threadpool-0.3.4/examples/test_shared_state/test_counter_async.py +42 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/hooks/pre-commit +2 -2
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/pyproject.toml +8 -1
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/_grouping.py +50 -2
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/_markers.py +23 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/_runner.py +201 -30
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/_version.py +2 -2
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/plugin.py +5 -2
- pytest_threadpool-0.3.4/tests/__init__.py +0 -0
- pytest_threadpool-0.3.4/tests/integration_tests/cases/__init__.py +0 -0
- pytest_threadpool-0.3.4/tests/integration_tests/cases/capture_print_parallel.py +13 -0
- pytest_threadpool-0.3.4/tests/integration_tests/cases/capture_two_groups.py +30 -0
- pytest_threadpool-0.3.4/tests/integration_tests/cases/fixture_func_from_conftest.py +38 -0
- pytest_threadpool-0.3.4/tests/integration_tests/cases/fixture_func_from_conftest_conftest.py +9 -0
- pytest_threadpool-0.3.4/tests/integration_tests/cases/fixture_func_implicit_scope.py +56 -0
- pytest_threadpool-0.3.4/tests/integration_tests/cases/reporting_single_file_params.py +12 -0
- pytest_threadpool-0.3.4/tests/integration_tests/cases/reporting_stdout_during_parallel.py +13 -0
- pytest_threadpool-0.3.4/tests/integration_tests/test_capture.py +172 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_edge_cases.py +5 -7
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_func_fixtures.py +23 -1
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_package.py +78 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_reporting.py +127 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/unit_tests/test_unit_grouping.py +65 -0
- pytest_threadpool-0.3.4/tests/unit_tests/test_unit_stream_proxy.py +122 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/.github/workflows/publish.yml +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/.gitignore +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/LICENSE +0 -0
- {pytest_threadpool-0.3.3/tests → pytest_threadpool-0.3.4/examples/test_logging}/__init__.py +0 -0
- {pytest_threadpool-0.3.3/tests/integration_tests/cases → pytest_threadpool-0.3.4/examples/test_queue}/__init__.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/scripts/setup-dev +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/__init__.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/_api.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/_constants.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/src/pytest_threadpool/_fixtures.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/__init__.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/_templates.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/class_barrier_concurrency.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/class_single_method.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/class_thread_verification.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/collected_count.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/edge_cross_module_group.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/edge_keyboard_interrupt.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/edge_nested_threads.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/edge_sigint.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/edge_sigint_many.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/edge_system_exit.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_autouse_function.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_class_scoped_once.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_class_yield.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_func_addfinalizer.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_func_chain.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_func_multiple_per_test.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_func_setup_parallel.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_func_with_shared_deps.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_func_with_tmp_path.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_func_with_xunit.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_func_yield_teardown.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_function_scoped.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_interdependent_finalizers.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_multiple_scopes.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_parameterized.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_teardown_exception.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/fixture_yield_cleanup.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/marks_custom.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/marks_standard.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/parallel_only_skip.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/reporting_cross_module.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/reporting_incremental.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/reporting_incremental_conftest.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/reporting_package_children.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_all_merged.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_all_not_parallelizable.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_children_separate_params.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_dynamic_parametrize.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_method_overrides_class.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_module_children.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_not_parallelizable_function.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_not_parallelizable_method.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/scope_parameters.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/sequential_bare_functions.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/sequential_unmarked_class.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/setup_all_fail.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/setup_mixed_pass_fail.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/shared_counter.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/shared_cross_group_non_pickleable.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/shared_dict_mutation.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/shared_non_pickleable.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/shared_two_phase_barrier.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/teardown_after_test_failure.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/teardown_parallel_timing.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/teardown_same_thread.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/teardown_xunit_function.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/teardown_xunit_method_thread.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/validate_threadpool.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/validate_threadpool_conftest.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/xunit_class_setup.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/xunit_combined_setup.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/xunit_function_setup.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/xunit_method_setup.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/xunit_method_teardown_runs.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/cases/xunit_module_setup.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/conftest.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_class.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_fixtures.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_marks.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_parallel_teardown.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_scopes.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_sequential.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_shared.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/integration_tests/test_pytester_xunit.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/unit_tests/__init__.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/unit_tests/test_unit_api.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/unit_tests/test_unit_fixtures.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/unit_tests/test_unit_markers.py +0 -0
- {pytest_threadpool-0.3.3 → pytest_threadpool-0.3.4}/tests/unit_tests/test_unit_plugin.py +0 -0
|
@@ -3,6 +3,7 @@ name: CI
|
|
|
3
3
|
on:
|
|
4
4
|
push:
|
|
5
5
|
branches: [main]
|
|
6
|
+
tags-ignore: ["v*"]
|
|
6
7
|
pull_request:
|
|
7
8
|
branches: [main]
|
|
8
9
|
workflow_call:
|
|
@@ -25,7 +26,7 @@ jobs:
|
|
|
25
26
|
strategy:
|
|
26
27
|
fail-fast: false
|
|
27
28
|
matrix:
|
|
28
|
-
python: ["3.13t", "3.14t", "3.15t"]
|
|
29
|
+
python: ["3.13", "3.13t", "3.14", "3.14t", "3.15", "3.15t"]
|
|
29
30
|
steps:
|
|
30
31
|
- uses: actions/checkout@v4
|
|
31
32
|
with:
|
|
@@ -13,7 +13,9 @@ This installs a free-threaded Python via uv, syncs all dependencies (including
|
|
|
13
13
|
dev tools), and sets up a pre-commit hook that runs `ruff format`, `ruff check`,
|
|
14
14
|
and `pyright` before each commit.
|
|
15
15
|
|
|
16
|
-
>
|
|
16
|
+
> Free-threaded builds (3.13t, 3.14t, 3.15t) ship with the GIL disabled and
|
|
17
|
+
> provide true parallelism. Standard builds (3.13, 3.14, 3.15) are also
|
|
18
|
+
> supported — threads work, but the GIL serializes CPU-bound code.
|
|
17
19
|
|
|
18
20
|
## Architecture rules
|
|
19
21
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pytest-threadpool
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.4
|
|
4
4
|
Summary: Parallel test execution for free-threaded Python builds
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -10,6 +10,9 @@ Classifier: Framework :: Pytest
|
|
|
10
10
|
Classifier: Intended Audience :: Developers
|
|
11
11
|
Classifier: License :: OSI Approved :: MIT License
|
|
12
12
|
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
13
16
|
Classifier: Topic :: Software Development :: Testing
|
|
14
17
|
Requires-Python: >=3.13
|
|
15
18
|
Requires-Dist: pytest>=9.0.2
|
|
@@ -18,18 +21,22 @@ Description-Content-Type: text/markdown
|
|
|
18
21
|
# pytest-threadpool
|
|
19
22
|
|
|
20
23
|
[](https://pypi.org/project/pytest-threadpool/)
|
|
21
|
-
[](https://pypi.org/project/pytest-threadpool/)
|
|
22
25
|
[](https://github.com/pytest-threadpool/pytest-threadpool/blob/main/LICENSE)
|
|
23
26
|
[](https://github.com/pytest-threadpool/pytest-threadpool)
|
|
24
27
|
[](https://github.com/pytest-threadpool/pytest-threadpool/actions/workflows/ci.yml)
|
|
25
28
|
[](https://codecov.io/gh/pytest-threadpool/pytest-threadpool)
|
|
26
29
|
|
|
27
|
-
**Status: Beta** · Parallel test execution
|
|
30
|
+
**Status: Beta** · Parallel test execution using threads.
|
|
28
31
|
|
|
29
32
|
Runs test *bodies*, function-scoped fixture setup, and function-scoped fixture
|
|
30
33
|
teardown concurrently in a thread pool while keeping shared fixtures
|
|
31
34
|
(module/class/session scope) sequential.
|
|
32
35
|
|
|
36
|
+
Works on any Python 3.13+. Free-threaded builds (3.13t, 3.14t, 3.15t) get true
|
|
37
|
+
parallelism for CPU-bound tests. Standard builds still benefit from parallel
|
|
38
|
+
execution of I/O-bound tests (network, database, file operations).
|
|
39
|
+
|
|
33
40
|
## Installation
|
|
34
41
|
|
|
35
42
|
```bash
|
|
@@ -167,9 +174,12 @@ pytest
|
|
|
167
174
|
|
|
168
175
|
| Component | Versions |
|
|
169
176
|
|-----------|----------|
|
|
170
|
-
| Python | 3.13t, 3.14t, 3.15t |
|
|
177
|
+
| Python | 3.13, 3.13t, 3.14, 3.14t, 3.15, 3.15t |
|
|
171
178
|
| pytest | >=9.0.2 |
|
|
172
179
|
|
|
180
|
+
> **Note:** On standard (GIL-enabled) builds, the GIL limits parallel speedup
|
|
181
|
+
> for CPU-bound tests. I/O-bound tests still run concurrently.
|
|
182
|
+
|
|
173
183
|
## Known limitations
|
|
174
184
|
|
|
175
185
|
- **Private pytest API usage** — The plugin relies on internal `_pytest` APIs
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
# pytest-threadpool
|
|
2
2
|
|
|
3
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)
|
|
8
8
|
[](https://codecov.io/gh/pytest-threadpool/pytest-threadpool)
|
|
9
9
|
|
|
10
|
-
**Status: Beta** · Parallel test execution
|
|
10
|
+
**Status: Beta** · Parallel test execution using threads.
|
|
11
11
|
|
|
12
12
|
Runs test *bodies*, function-scoped fixture setup, and function-scoped fixture
|
|
13
13
|
teardown concurrently in a thread pool while keeping shared fixtures
|
|
14
14
|
(module/class/session scope) sequential.
|
|
15
15
|
|
|
16
|
+
Works on any Python 3.13+. Free-threaded builds (3.13t, 3.14t, 3.15t) get true
|
|
17
|
+
parallelism for CPU-bound tests. Standard builds still benefit from parallel
|
|
18
|
+
execution of I/O-bound tests (network, database, file operations).
|
|
19
|
+
|
|
16
20
|
## Installation
|
|
17
21
|
|
|
18
22
|
```bash
|
|
@@ -150,9 +154,12 @@ pytest
|
|
|
150
154
|
|
|
151
155
|
| Component | Versions |
|
|
152
156
|
|-----------|----------|
|
|
153
|
-
| Python | 3.13t, 3.14t, 3.15t |
|
|
157
|
+
| Python | 3.13, 3.13t, 3.14, 3.14t, 3.15, 3.15t |
|
|
154
158
|
| pytest | >=9.0.2 |
|
|
155
159
|
|
|
160
|
+
> **Note:** On standard (GIL-enabled) builds, the GIL limits parallel speedup
|
|
161
|
+
> for CPU-bound tests. I/O-bound tests still run concurrently.
|
|
162
|
+
|
|
156
163
|
## Known limitations
|
|
157
164
|
|
|
158
165
|
- **Private pytest API usage** — The plugin relies on internal `_pytest` APIs
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Fixtures resolving from the static DI container.
|
|
2
|
+
|
|
3
|
+
- ``request_handler`` injects a fresh RequestHandler per test (Factory).
|
|
4
|
+
- ``test_context`` injects a per-test TestContext (ContextLocal, reset per test).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from examples.test_di.container import Container
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def request_handler():
|
|
14
|
+
"""Fresh RequestHandler per test (Factory provider)."""
|
|
15
|
+
return Container.request_handler()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.fixture
|
|
19
|
+
def test_context():
|
|
20
|
+
"""Per-test TestContext — ContextLocal + reset = test-local scope.
|
|
21
|
+
|
|
22
|
+
ContextLocal uses contextvars, so the instance follows the execution
|
|
23
|
+
context — not the OS thread. Safe across await boundaries.
|
|
24
|
+
The reset() in teardown gives the next test a fresh instance.
|
|
25
|
+
"""
|
|
26
|
+
ctx = Container.test_context()
|
|
27
|
+
yield ctx
|
|
28
|
+
Container.test_context.reset()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""DI container wiring four provider scopes — pure stdlib, no GIL re-enablement.
|
|
2
|
+
|
|
3
|
+
- Singleton: one Config for the entire session
|
|
4
|
+
- ThreadLocal: one DbConnection per worker thread
|
|
5
|
+
- ContextLocal: one TestContext per test (survives await, reset between tests)
|
|
6
|
+
- Factory: fresh RequestHandler every injection
|
|
7
|
+
|
|
8
|
+
All providers are class attributes — use ``Container.config()`` etc. directly.
|
|
9
|
+
|
|
10
|
+
.. note::
|
|
11
|
+
|
|
12
|
+
The `dependency-injector <https://github.com/ets-labs/python-dependency-injector>`_
|
|
13
|
+
library offers similar provider scopes, but its C-extension is not marked as
|
|
14
|
+
free-threaded safe. On 3.13t+ builds Python will fall back to the GIL-enabled
|
|
15
|
+
interpreter when it is imported, negating the benefit of free-threaded execution.
|
|
16
|
+
This example uses pure-stdlib providers to avoid that limitation.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from examples.test_di.providers import ContextLocal, Factory, Singleton, ThreadLocal
|
|
20
|
+
from examples.test_di.services import Config, DbConnection, RequestHandler, TestContext
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Container:
|
|
24
|
+
config = Singleton(
|
|
25
|
+
Config,
|
|
26
|
+
db_url="postgresql://localhost/testdb",
|
|
27
|
+
pool_size=4,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
db_connection = ThreadLocal(
|
|
31
|
+
DbConnection,
|
|
32
|
+
config=config,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
test_context = ContextLocal(
|
|
36
|
+
TestContext,
|
|
37
|
+
config=config,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
request_handler = Factory(
|
|
41
|
+
RequestHandler,
|
|
42
|
+
db=db_connection,
|
|
43
|
+
config=config,
|
|
44
|
+
)
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Pure-stdlib DI providers — no C extensions, no GIL re-enablement.
|
|
2
|
+
|
|
3
|
+
Four scopes built on threading and contextvars primitives:
|
|
4
|
+
|
|
5
|
+
- ``Singleton``: one instance for the process lifetime (thread-safe).
|
|
6
|
+
- ``ThreadLocal``: one instance per OS thread.
|
|
7
|
+
- ``ContextLocal``: one instance per execution context (survives await).
|
|
8
|
+
- ``Factory``: fresh instance on every call.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import contextlib
|
|
12
|
+
import contextvars
|
|
13
|
+
import threading
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class Singleton:
|
|
18
|
+
"""Thread-safe singleton — one instance for the entire process."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, cls: type, **kwargs: Any):
|
|
21
|
+
self._cls = cls
|
|
22
|
+
self._kwargs = kwargs
|
|
23
|
+
self._instance: Any = None
|
|
24
|
+
self._lock = threading.Lock()
|
|
25
|
+
|
|
26
|
+
def __call__(self) -> Any:
|
|
27
|
+
if self._instance is None:
|
|
28
|
+
with self._lock:
|
|
29
|
+
if self._instance is None:
|
|
30
|
+
self._instance = self._cls(**self._resolve_kwargs())
|
|
31
|
+
return self._instance
|
|
32
|
+
|
|
33
|
+
def reset(self) -> None:
|
|
34
|
+
with self._lock:
|
|
35
|
+
self._instance = None
|
|
36
|
+
|
|
37
|
+
def _resolve_kwargs(self) -> dict:
|
|
38
|
+
return {k: v() if callable(v) else v for k, v in self._kwargs.items()}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ThreadLocal:
|
|
42
|
+
"""Thread-local singleton — one instance per OS thread."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, cls: type, **kwargs: Any):
|
|
45
|
+
self._cls = cls
|
|
46
|
+
self._kwargs = kwargs
|
|
47
|
+
self._local = threading.local()
|
|
48
|
+
|
|
49
|
+
def __call__(self) -> Any:
|
|
50
|
+
try:
|
|
51
|
+
return self._local.instance
|
|
52
|
+
except AttributeError:
|
|
53
|
+
self._local.instance = self._cls(**self._resolve_kwargs())
|
|
54
|
+
return self._local.instance
|
|
55
|
+
|
|
56
|
+
def reset(self) -> None:
|
|
57
|
+
with contextlib.suppress(AttributeError):
|
|
58
|
+
del self._local.instance
|
|
59
|
+
|
|
60
|
+
def _resolve_kwargs(self) -> dict:
|
|
61
|
+
return {k: v() if callable(v) else v for k, v in self._kwargs.items()}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class ContextLocal:
|
|
65
|
+
"""Context-local singleton — one instance per execution context.
|
|
66
|
+
|
|
67
|
+
Uses ``contextvars.ContextVar``, so the instance follows the async
|
|
68
|
+
execution flow across ``await`` boundaries, even if the coroutine
|
|
69
|
+
resumes on a different OS thread.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
_SENTINEL = object()
|
|
73
|
+
|
|
74
|
+
def __init__(self, cls: type, **kwargs: Any):
|
|
75
|
+
self._cls = cls
|
|
76
|
+
self._kwargs = kwargs
|
|
77
|
+
self._var: contextvars.ContextVar = contextvars.ContextVar(f"_di_{cls.__name__}")
|
|
78
|
+
|
|
79
|
+
def __call__(self) -> Any:
|
|
80
|
+
instance = self._var.get(self._SENTINEL)
|
|
81
|
+
if instance is self._SENTINEL:
|
|
82
|
+
instance = self._cls(**self._resolve_kwargs())
|
|
83
|
+
self._var.set(instance)
|
|
84
|
+
return instance
|
|
85
|
+
|
|
86
|
+
def reset(self) -> None:
|
|
87
|
+
self._var.set(self._SENTINEL) # type: ignore[arg-type]
|
|
88
|
+
|
|
89
|
+
def _resolve_kwargs(self) -> dict:
|
|
90
|
+
return {k: v() if callable(v) else v for k, v in self._kwargs.items()}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class Factory:
|
|
94
|
+
"""Factory — fresh instance on every call."""
|
|
95
|
+
|
|
96
|
+
def __init__(self, cls: type, **kwargs: Any):
|
|
97
|
+
self._cls = cls
|
|
98
|
+
self._kwargs = kwargs
|
|
99
|
+
|
|
100
|
+
def __call__(self) -> Any:
|
|
101
|
+
return self._cls(**self._resolve_kwargs())
|
|
102
|
+
|
|
103
|
+
def _resolve_kwargs(self) -> dict:
|
|
104
|
+
return {k: v() if callable(v) else v for k, v in self._kwargs.items()}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""Minimal service classes for the DI example."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
import uuid
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class Config:
|
|
8
|
+
"""Application config — intended as a Singleton (shared across all tests)."""
|
|
9
|
+
|
|
10
|
+
def __init__(self, db_url: str, pool_size: int):
|
|
11
|
+
self.db_url = db_url
|
|
12
|
+
self.pool_size = pool_size
|
|
13
|
+
self.instance_id = uuid.uuid4()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DbConnection:
|
|
17
|
+
"""Database connection — intended as ThreadLocal (one per worker thread).
|
|
18
|
+
|
|
19
|
+
In a real app this would be sqlalchemy.Session, asyncpg.Connection, etc.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
def __init__(self, config: Config):
|
|
23
|
+
self.config = config
|
|
24
|
+
self.instance_id = uuid.uuid4()
|
|
25
|
+
self.thread_id = threading.current_thread().ident
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class TestContext:
|
|
29
|
+
"""Per-test context — intended as ContextLocal (one per execution context).
|
|
30
|
+
|
|
31
|
+
In a real app this would be a request context, unit-of-work, or
|
|
32
|
+
transaction that should be the same instance within one test
|
|
33
|
+
but fresh for each test. Any function can resolve it from the
|
|
34
|
+
container without receiving it as a parameter.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: Config):
|
|
38
|
+
self.config = config
|
|
39
|
+
self.instance_id = uuid.uuid4()
|
|
40
|
+
self.data: dict = {}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RequestHandler:
|
|
44
|
+
"""Request handler — intended as Factory (fresh instance per injection).
|
|
45
|
+
|
|
46
|
+
In a real app this would be a use-case, service, or controller object
|
|
47
|
+
that gets a fresh instance per test / per request.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, db: DbConnection, config: Config):
|
|
51
|
+
self.db = db
|
|
52
|
+
self.config = config
|
|
53
|
+
self.instance_id = uuid.uuid4()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Factory scope: fresh RequestHandler for every injection."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestFactory:
|
|
10
|
+
"""RequestHandler is a Factory — every injection must be a fresh instance."""
|
|
11
|
+
|
|
12
|
+
_seen_ids: ClassVar[set] = set()
|
|
13
|
+
_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
16
|
+
def test_handler_is_fresh(self, request_handler, _worker):
|
|
17
|
+
with self._lock:
|
|
18
|
+
assert request_handler.instance_id not in self._seen_ids, "Factory reused an instance"
|
|
19
|
+
self._seen_ids.add(request_handler.instance_id)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""ContextLocal (per-test) scope: same instance within a test, fresh between tests.
|
|
2
|
+
|
|
3
|
+
Pattern: ContextLocal provider + per-test reset in fixture teardown.
|
|
4
|
+
|
|
5
|
+
Any function can resolve ``Container.test_context()`` and get the same
|
|
6
|
+
instance as the calling test — even across ``await`` boundaries that may
|
|
7
|
+
resume on a different thread. ``ContextLocal`` uses ``contextvars``
|
|
8
|
+
under the hood, so the context follows the execution flow, not the OS thread.
|
|
9
|
+
|
|
10
|
+
Parallel tests each run in their own context, so writes in one test
|
|
11
|
+
never leak into another.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import functools
|
|
16
|
+
import threading
|
|
17
|
+
from typing import ClassVar
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
|
|
21
|
+
from examples.test_di.container import Container
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def async_test(fn):
|
|
25
|
+
"""Run an async test function in its own event loop."""
|
|
26
|
+
|
|
27
|
+
@functools.wraps(fn)
|
|
28
|
+
def wrapper(*args, **kwargs):
|
|
29
|
+
return asyncio.run(fn(*args, **kwargs))
|
|
30
|
+
|
|
31
|
+
return wrapper
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _helper_that_reads_from_container() -> dict:
|
|
35
|
+
"""Simulate a function deep in the call stack resolving from the container."""
|
|
36
|
+
return Container.test_context().data
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _helper_that_writes_to_container(key: str, value: str) -> None:
|
|
40
|
+
"""Simulate a function deep in the call stack writing to the context."""
|
|
41
|
+
Container.test_context().data[key] = value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
async def _async_helper_that_reads_from_container() -> dict:
|
|
45
|
+
"""Async function that resolves the context — may run on a different thread."""
|
|
46
|
+
await asyncio.sleep(0.01)
|
|
47
|
+
return Container.test_context().data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TestLocal:
|
|
51
|
+
"""Each test gets its own TestContext via ContextLocal + reset."""
|
|
52
|
+
|
|
53
|
+
_seen_ids: ClassVar[set] = set()
|
|
54
|
+
_lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
57
|
+
def test_write_then_read_from_container(self, test_context, _worker):
|
|
58
|
+
"""Test writes to context, a helper resolves the same context from the container."""
|
|
59
|
+
test_context.data["worker"] = str(_worker)
|
|
60
|
+
|
|
61
|
+
data = _helper_that_reads_from_container()
|
|
62
|
+
|
|
63
|
+
assert data["worker"] == str(_worker)
|
|
64
|
+
assert data is test_context.data
|
|
65
|
+
|
|
66
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
67
|
+
def test_helper_writes_visible_to_test(self, test_context, _worker):
|
|
68
|
+
"""A helper writes via the container, the test sees it through its fixture."""
|
|
69
|
+
_helper_that_writes_to_container("source", "helper")
|
|
70
|
+
|
|
71
|
+
assert test_context.data["source"] == "helper"
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
74
|
+
def test_parallel_tests_are_isolated(self, test_context, _worker):
|
|
75
|
+
"""Each parallel test starts with an empty context — no cross-test leakage."""
|
|
76
|
+
assert test_context.data == {}, f"Context leaked from another test: {test_context.data}"
|
|
77
|
+
test_context.data["mine"] = str(_worker)
|
|
78
|
+
|
|
79
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
80
|
+
def test_context_is_fresh_per_test(self, test_context, _worker):
|
|
81
|
+
"""Each test invocation gets a unique TestContext instance."""
|
|
82
|
+
with self._lock:
|
|
83
|
+
assert test_context.instance_id not in self._seen_ids, (
|
|
84
|
+
"ContextLocal+reset reused an instance across tests"
|
|
85
|
+
)
|
|
86
|
+
self._seen_ids.add(test_context.instance_id)
|
|
87
|
+
|
|
88
|
+
@async_test
|
|
89
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
90
|
+
async def test_context_survives_await(self, test_context, _worker):
|
|
91
|
+
"""Context is preserved across await boundaries that may switch threads."""
|
|
92
|
+
test_context.data["before_await"] = str(_worker)
|
|
93
|
+
|
|
94
|
+
await asyncio.sleep(0.01)
|
|
95
|
+
|
|
96
|
+
data = await _async_helper_that_reads_from_container()
|
|
97
|
+
|
|
98
|
+
assert data["before_await"] == str(_worker)
|
|
99
|
+
assert data is test_context.data
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Singleton scope: one Config instance shared across all tests."""
|
|
2
|
+
|
|
3
|
+
import threading
|
|
4
|
+
from typing import ClassVar
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestSingleton:
|
|
10
|
+
"""Config is a Singleton — every test must see the exact same instance."""
|
|
11
|
+
|
|
12
|
+
_seen_ids: ClassVar[set] = set()
|
|
13
|
+
_lock = threading.Lock()
|
|
14
|
+
|
|
15
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
16
|
+
def test_config_is_shared(self, request_handler, _worker):
|
|
17
|
+
config = request_handler.config
|
|
18
|
+
with self._lock:
|
|
19
|
+
self._seen_ids.add(config.instance_id)
|
|
20
|
+
assert len(self._seen_ids) == 1, f"Singleton produced {len(self._seen_ids)} instances"
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""ThreadLocal scope: one DbConnection per worker thread.
|
|
2
|
+
|
|
3
|
+
ThreadLocal binds to the OS thread. This means it does NOT survive
|
|
4
|
+
``await`` boundaries — if the event loop resumes a coroutine on a
|
|
5
|
+
different thread, a new instance is created. Use ContextLocal
|
|
6
|
+
(see test_local.py) when you need context that follows the execution
|
|
7
|
+
flow across awaits.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import functools
|
|
12
|
+
import threading
|
|
13
|
+
from typing import ClassVar
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from examples.test_di.container import Container
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def async_test(fn):
|
|
21
|
+
"""Run an async test function in its own event loop."""
|
|
22
|
+
|
|
23
|
+
@functools.wraps(fn)
|
|
24
|
+
def wrapper(*args, **kwargs):
|
|
25
|
+
return asyncio.run(fn(*args, **kwargs))
|
|
26
|
+
|
|
27
|
+
return wrapper
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TestThreadLocal:
|
|
31
|
+
"""DbConnection is ThreadLocal — same instance within a thread,
|
|
32
|
+
different instances across threads.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
_ids_by_thread: ClassVar[dict] = {}
|
|
36
|
+
_lock = threading.Lock()
|
|
37
|
+
|
|
38
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
39
|
+
def test_db_is_per_thread(self, request_handler, _worker):
|
|
40
|
+
db = request_handler.db
|
|
41
|
+
tid = threading.current_thread().ident
|
|
42
|
+
with self._lock:
|
|
43
|
+
if tid in self._ids_by_thread:
|
|
44
|
+
assert self._ids_by_thread[tid] == db.instance_id, (
|
|
45
|
+
"ThreadLocal returned different instance on same thread"
|
|
46
|
+
)
|
|
47
|
+
else:
|
|
48
|
+
self._ids_by_thread[tid] = db.instance_id
|
|
49
|
+
|
|
50
|
+
@async_test
|
|
51
|
+
@pytest.mark.parametrize("_worker", range(6))
|
|
52
|
+
async def test_thread_local_does_not_survive_await(self, _worker):
|
|
53
|
+
"""ThreadLocal may return a different instance after an await.
|
|
54
|
+
|
|
55
|
+
If ``asyncio.run()`` uses a thread pool internally, an ``await``
|
|
56
|
+
can resume on a different OS thread — and ThreadLocal is keyed
|
|
57
|
+
by thread, so it produces a new instance. This is the key
|
|
58
|
+
difference from ContextLocal.
|
|
59
|
+
"""
|
|
60
|
+
before = Container.db_connection()
|
|
61
|
+
before_tid = threading.current_thread().ident
|
|
62
|
+
|
|
63
|
+
results: dict = {}
|
|
64
|
+
|
|
65
|
+
async def resolve_after_await():
|
|
66
|
+
await asyncio.sleep(0.01)
|
|
67
|
+
results["db"] = Container.db_connection()
|
|
68
|
+
results["tid"] = threading.current_thread().ident
|
|
69
|
+
|
|
70
|
+
await resolve_after_await()
|
|
71
|
+
|
|
72
|
+
if before_tid != results["tid"]:
|
|
73
|
+
assert before.instance_id != results["db"].instance_id, (
|
|
74
|
+
"ThreadLocal returned same instance on different thread"
|
|
75
|
+
)
|
|
76
|
+
else:
|
|
77
|
+
assert before.instance_id == results["db"].instance_id
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Per-test file logging — each test writes to its own log file.
|
|
2
|
+
|
|
3
|
+
When you need persistent debug output that survives the test run
|
|
4
|
+
(e.g. for CI artifacts), write to a file in ``tmp_path``. Each test
|
|
5
|
+
gets its own temporary directory, so no coordination is needed.
|
|
6
|
+
|
|
7
|
+
After the run, inspect logs in the ``tmp_path_factory`` base dir
|
|
8
|
+
or configure CI to upload the ``/tmp/pytest-*`` tree as artifacts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from time import sleep
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TestFileLogging:
|
|
17
|
+
"""Each test writes debug output to its own temp file."""
|
|
18
|
+
|
|
19
|
+
@pytest.mark.parametrize("_worker", range(4))
|
|
20
|
+
def test_per_test_log_file(self, _worker, tmp_path):
|
|
21
|
+
"""Write debug data to a file in tmp_path — isolated per test."""
|
|
22
|
+
log_file = tmp_path / "debug.log"
|
|
23
|
+
|
|
24
|
+
with log_file.open("w") as f:
|
|
25
|
+
f.write(f"worker {_worker}: starting\n")
|
|
26
|
+
sleep(0.01)
|
|
27
|
+
f.write(f"worker {_worker}: done\n")
|
|
28
|
+
|
|
29
|
+
lines = log_file.read_text().splitlines()
|
|
30
|
+
assert lines == [f"worker {_worker}: starting", f"worker {_worker}: done"]
|