pytest-shm 0.1.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.
- pytest_shm-0.1.0/.gitignore +21 -0
- pytest_shm-0.1.0/LICENSE +21 -0
- pytest_shm-0.1.0/PKG-INFO +163 -0
- pytest_shm-0.1.0/README.md +132 -0
- pytest_shm-0.1.0/pyproject.toml +119 -0
- pytest_shm-0.1.0/src/pytest_shm/__init__.py +9 -0
- pytest_shm-0.1.0/src/pytest_shm/_version.py +24 -0
- pytest_shm-0.1.0/src/pytest_shm/plugin.py +184 -0
- pytest_shm-0.1.0/src/pytest_shm/py.typed +0 -0
- pytest_shm-0.1.0/tests/__init__.py +1 -0
- pytest_shm-0.1.0/tests/conftest.py +3 -0
- pytest_shm-0.1.0/tests/test_plugin.py +403 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
src/pytest_shm/_version.py
|
|
4
|
+
*.py[cod]
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
*.egg-info/
|
|
8
|
+
|
|
9
|
+
# Virtual environments
|
|
10
|
+
.venv/
|
|
11
|
+
|
|
12
|
+
# IDE
|
|
13
|
+
.idea/
|
|
14
|
+
.vscode/
|
|
15
|
+
*.swp
|
|
16
|
+
.DS_Store
|
|
17
|
+
|
|
18
|
+
# Tools
|
|
19
|
+
.pytest_cache/
|
|
20
|
+
.mypy_cache/
|
|
21
|
+
.ruff_cache/
|
pytest_shm-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bas Nijholt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-shm
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Put pytest's temporary files on the /dev/shm tmpfs so fsync-heavy suites stop waiting on the disk
|
|
5
|
+
Project-URL: Homepage, https://github.com/basnijholt/pytest-shm
|
|
6
|
+
Project-URL: Repository, https://github.com/basnijholt/pytest-shm
|
|
7
|
+
Project-URL: Documentation, https://github.com/basnijholt/pytest-shm#readme
|
|
8
|
+
Project-URL: Issues, https://github.com/basnijholt/pytest-shm/issues
|
|
9
|
+
Project-URL: Changelog, https://github.com/basnijholt/pytest-shm/releases
|
|
10
|
+
Author-email: Bas Nijholt <bas@nijho.lt>
|
|
11
|
+
Maintainer-email: Bas Nijholt <bas@nijho.lt>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Keywords: fsync,performance,pytest,shm,tempfile,testing,tmpfs,xdist
|
|
15
|
+
Classifier: Development Status :: 4 - Beta
|
|
16
|
+
Classifier: Framework :: Pytest
|
|
17
|
+
Classifier: Intended Audience :: Developers
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
26
|
+
Classifier: Topic :: Software Development :: Testing
|
|
27
|
+
Classifier: Typing :: Typed
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Requires-Dist: pytest>=8.4
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
|
|
32
|
+
# pytest-shm
|
|
33
|
+
|
|
34
|
+
[](https://pypi.org/project/pytest-shm/)
|
|
35
|
+
[](https://pypi.org/project/pytest-shm/)
|
|
36
|
+
[](LICENSE)
|
|
37
|
+
[](https://github.com/basnijholt/pytest-shm/actions/workflows/ci.yml)
|
|
38
|
+
|
|
39
|
+
A pytest plugin that puts your test suite's temporary files on the `/dev/shm` tmpfs, so tests that fsync stop waiting on the disk.
|
|
40
|
+
|
|
41
|
+
> [!NOTE]
|
|
42
|
+
> Install it and run pytest.
|
|
43
|
+
> On Linux it moves the temp root to `/dev/shm` when that is safe, keeps stray `tempfile` output from collection on inside pytest's base directory, and frees that directory when the session passes.
|
|
44
|
+
> Everywhere else, and whenever you export `TMPDIR`, it leaves the temp root alone.
|
|
45
|
+
|
|
46
|
+
## Table of Contents
|
|
47
|
+
|
|
48
|
+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
|
49
|
+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
|
50
|
+
|
|
51
|
+
- [Why](#why)
|
|
52
|
+
- [Installation](#installation)
|
|
53
|
+
- [How it works](#how-it-works)
|
|
54
|
+
- [When it stays off](#when-it-stays-off)
|
|
55
|
+
- [Configuration](#configuration)
|
|
56
|
+
- [Caveats](#caveats)
|
|
57
|
+
- [Development](#development)
|
|
58
|
+
- [License](#license)
|
|
59
|
+
|
|
60
|
+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
|
61
|
+
|
|
62
|
+
## Why
|
|
63
|
+
|
|
64
|
+
SQLite commits, atomic file replacement, and anything else that promises durability call `fsync`, and on a real disk each call waits for the device.
|
|
65
|
+
A suite that exercises durable storage can spend most of its time there.
|
|
66
|
+
tmpfs lives in memory, so `fsync` returns immediately.
|
|
67
|
+
|
|
68
|
+
In [MindRoom](https://github.com/mindroom-ai/mindroom)'s suite of about 26,000 tests, summed test time on a 32-worker NVMe machine fell from 5236 s to 1426 s, and the GitHub Actions test step fell from about 16 to 12-15 minutes.
|
|
69
|
+
|
|
70
|
+
No durability test can observe the difference.
|
|
71
|
+
Such tests simulate a crashed process, and a crashed process never needed its writes to leave the page cache.
|
|
72
|
+
The [caveats](#caveats) list the differences other tests can see.
|
|
73
|
+
|
|
74
|
+
## Installation
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv add --dev pytest-shm
|
|
78
|
+
# or
|
|
79
|
+
pip install pytest-shm
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
It needs Python 3.10+ and pytest 8.4+, and pytest loads it automatically.
|
|
83
|
+
The report header tells you whether it is active:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
shm: temp root /dev/shm
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
or why it is not:
|
|
90
|
+
|
|
91
|
+
```text
|
|
92
|
+
shm: off, TMPDIR is exported
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## How it works
|
|
96
|
+
|
|
97
|
+
1. **Temp root.** Before any `conftest.py` is imported, the plugin sets `TMPDIR=/dev/shm`, so `tmp_path`, `tmp_path_factory`, and every `tempfile` call land in memory.
|
|
98
|
+
2. **Containment.** Tests and the code they drive often call `tempfile.mkdtemp()` without removing the result. On disk that clutters `/tmp`; on tmpfs it would hold memory until reboot. When the session starts, before collection, the plugin points `TMPDIR` at `<basetemp>/shm-tmp`, so that output lives and dies with pytest's own base directory.
|
|
99
|
+
3. **Cleanup.** pytest keeps the last three sessions' base directories. When a session passes, collects no tests, or stops at a usage error, the plugin deletes its base directory right away instead of holding it in memory. A failing session keeps everything for inspection.
|
|
100
|
+
4. **pytest-xdist.** Each worker owns `<basetemp>/popen-gwN` and cleans up after itself, so a failing run keeps only the directories of workers that saw a failure. A `--basetemp` you pass yourself is never deleted, with or without xdist.
|
|
101
|
+
|
|
102
|
+
Deleting per test is deliberately not offered: pytest would then reuse the freed directory names, and caches keyed by path would hand the next test the previous one's state.
|
|
103
|
+
|
|
104
|
+
## When it stays off
|
|
105
|
+
|
|
106
|
+
The plugin leaves the temp root alone, and says why in the report header, when any of these holds:
|
|
107
|
+
|
|
108
|
+
| Condition | Header |
|
|
109
|
+
| --- | --- |
|
|
110
|
+
| Not running on Linux | `shm: off, not Linux` |
|
|
111
|
+
| `TMPDIR`, `TEMP`, or `TMP` is exported | `shm: off, TMPDIR is exported` |
|
|
112
|
+
| `--basetemp` or `PYTEST_DEBUG_TEMPROOT` puts pytest's base directory outside `/dev/shm` | `shm: off, --basetemp is outside /dev/shm` |
|
|
113
|
+
| pytest's `tmpdir` plugin is disabled (`-p no:tmpdir`) | `shm: off, pytest's tmpdir plugin is disabled` |
|
|
114
|
+
| `/dev/shm` is missing, or not writable and searchable | `shm: off, /dev/shm is missing or not writable` |
|
|
115
|
+
| `/dev/shm` is mounted `noexec` (Docker's default), which would break tests that run scripts they write | `shm: off, /dev/shm is mounted noexec` |
|
|
116
|
+
| `/dev/shm` has less free space than `shm_min_free_gib` (Docker's default is 64 MiB) | `shm: off, /dev/shm has 0.1 GiB free, below shm_min_free_gib = 1` |
|
|
117
|
+
|
|
118
|
+
To turn it off explicitly, export `TMPDIR` to the directory you want, or pass `-o shm_min_free_gib=inf`.
|
|
119
|
+
`-p no:shm` works too, but pytest then warns about the unknown `shm_min_free_gib` option if you configured it, and `--strict-config` makes that an error.
|
|
120
|
+
|
|
121
|
+
If you export `TMPDIR=/dev/shm` yourself, the plugin still contains and cleans up temporary files, as long as pytest's base directory is on `/dev/shm` too.
|
|
122
|
+
|
|
123
|
+
## Configuration
|
|
124
|
+
|
|
125
|
+
One ini option sets how much free space `/dev/shm` needs before the plugin uses it:
|
|
126
|
+
|
|
127
|
+
```toml
|
|
128
|
+
[tool.pytest.ini_options]
|
|
129
|
+
shm_min_free_gib = 4
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The default is `1`.
|
|
133
|
+
Set it above your suite's peak usage, which you can watch with `df -h /dev/shm` during a run.
|
|
134
|
+
Override it for one run with `-o shm_min_free_gib=8`.
|
|
135
|
+
|
|
136
|
+
## Caveats
|
|
137
|
+
|
|
138
|
+
- **Only output during the session is contained.** Temporary files created before the session starts (while the initial `conftest.py` files are imported, in `pytest_configure`, or in other plugins' `pytest_sessionstart` hooks) or after it ends (`pytest_terminal_summary`, `pytest_unconfigure`) land directly in `/dev/shm` and stay there until reboot. Create them in fixtures, or remove them yourself.
|
|
139
|
+
- **Caches under the temp root become per-session.** Libraries that cache downloads under `tempfile.gettempdir()` see the contained directory, which the plugin frees after the session. Pin such caches in your root `conftest.py`, where `tempfile.gettempdir()` is still `/dev/shm`. For tiktoken:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
if "TIKTOKEN_CACHE_DIR" not in os.environ and "DATA_GYM_CACHE_DIR" not in os.environ:
|
|
143
|
+
os.environ["TIKTOKEN_CACHE_DIR"] = str(Path(tempfile.gettempdir()) / "data-gym-cache")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
- **Temp files live on another filesystem.** `os.rename` or `os.replace` from a temporary file into your project fails with `EXDEV`, as it already does wherever `/tmp` is tmpfs.
|
|
147
|
+
- **Paths get longer.** `/dev/shm/pytest-of-<user>/pytest-N/popen-gwN/shm-tmp/tmpXXXXXXXX` is much longer than `/tmp/tmpXXXXXXXX`, which matters for the 107-byte limit on `AF_UNIX` socket paths.
|
|
148
|
+
- **Files use RAM.** Everything a session writes counts against memory until the session ends. Base directories of failing sessions stay until the machine reboots or later failing sessions push them out of pytest's retention of three numbered directories; passing sessions reuse the freed number instead of advancing it.
|
|
149
|
+
- **Plugin autoloading.** With `PYTEST_DISABLE_PLUGIN_AUTOLOAD` set, pass `-p shm` to load the plugin.
|
|
150
|
+
|
|
151
|
+
## Development
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
just install # uv sync --dev
|
|
155
|
+
just test # uv run pytest -n auto
|
|
156
|
+
just lint # ruff, mypy, ty
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The tests run real pytest sessions in subprocesses with `pytester` and inspect what they leave behind on `/dev/shm`.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# pytest-shm
|
|
2
|
+
|
|
3
|
+
[](https://pypi.org/project/pytest-shm/)
|
|
4
|
+
[](https://pypi.org/project/pytest-shm/)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](https://github.com/basnijholt/pytest-shm/actions/workflows/ci.yml)
|
|
7
|
+
|
|
8
|
+
A pytest plugin that puts your test suite's temporary files on the `/dev/shm` tmpfs, so tests that fsync stop waiting on the disk.
|
|
9
|
+
|
|
10
|
+
> [!NOTE]
|
|
11
|
+
> Install it and run pytest.
|
|
12
|
+
> On Linux it moves the temp root to `/dev/shm` when that is safe, keeps stray `tempfile` output from collection on inside pytest's base directory, and frees that directory when the session passes.
|
|
13
|
+
> Everywhere else, and whenever you export `TMPDIR`, it leaves the temp root alone.
|
|
14
|
+
|
|
15
|
+
## Table of Contents
|
|
16
|
+
|
|
17
|
+
<!-- START doctoc generated TOC please keep comment here to allow auto update -->
|
|
18
|
+
<!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
|
|
19
|
+
|
|
20
|
+
- [Why](#why)
|
|
21
|
+
- [Installation](#installation)
|
|
22
|
+
- [How it works](#how-it-works)
|
|
23
|
+
- [When it stays off](#when-it-stays-off)
|
|
24
|
+
- [Configuration](#configuration)
|
|
25
|
+
- [Caveats](#caveats)
|
|
26
|
+
- [Development](#development)
|
|
27
|
+
- [License](#license)
|
|
28
|
+
|
|
29
|
+
<!-- END doctoc generated TOC please keep comment here to allow auto update -->
|
|
30
|
+
|
|
31
|
+
## Why
|
|
32
|
+
|
|
33
|
+
SQLite commits, atomic file replacement, and anything else that promises durability call `fsync`, and on a real disk each call waits for the device.
|
|
34
|
+
A suite that exercises durable storage can spend most of its time there.
|
|
35
|
+
tmpfs lives in memory, so `fsync` returns immediately.
|
|
36
|
+
|
|
37
|
+
In [MindRoom](https://github.com/mindroom-ai/mindroom)'s suite of about 26,000 tests, summed test time on a 32-worker NVMe machine fell from 5236 s to 1426 s, and the GitHub Actions test step fell from about 16 to 12-15 minutes.
|
|
38
|
+
|
|
39
|
+
No durability test can observe the difference.
|
|
40
|
+
Such tests simulate a crashed process, and a crashed process never needed its writes to leave the page cache.
|
|
41
|
+
The [caveats](#caveats) list the differences other tests can see.
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
uv add --dev pytest-shm
|
|
47
|
+
# or
|
|
48
|
+
pip install pytest-shm
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
It needs Python 3.10+ and pytest 8.4+, and pytest loads it automatically.
|
|
52
|
+
The report header tells you whether it is active:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
shm: temp root /dev/shm
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
or why it is not:
|
|
59
|
+
|
|
60
|
+
```text
|
|
61
|
+
shm: off, TMPDIR is exported
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## How it works
|
|
65
|
+
|
|
66
|
+
1. **Temp root.** Before any `conftest.py` is imported, the plugin sets `TMPDIR=/dev/shm`, so `tmp_path`, `tmp_path_factory`, and every `tempfile` call land in memory.
|
|
67
|
+
2. **Containment.** Tests and the code they drive often call `tempfile.mkdtemp()` without removing the result. On disk that clutters `/tmp`; on tmpfs it would hold memory until reboot. When the session starts, before collection, the plugin points `TMPDIR` at `<basetemp>/shm-tmp`, so that output lives and dies with pytest's own base directory.
|
|
68
|
+
3. **Cleanup.** pytest keeps the last three sessions' base directories. When a session passes, collects no tests, or stops at a usage error, the plugin deletes its base directory right away instead of holding it in memory. A failing session keeps everything for inspection.
|
|
69
|
+
4. **pytest-xdist.** Each worker owns `<basetemp>/popen-gwN` and cleans up after itself, so a failing run keeps only the directories of workers that saw a failure. A `--basetemp` you pass yourself is never deleted, with or without xdist.
|
|
70
|
+
|
|
71
|
+
Deleting per test is deliberately not offered: pytest would then reuse the freed directory names, and caches keyed by path would hand the next test the previous one's state.
|
|
72
|
+
|
|
73
|
+
## When it stays off
|
|
74
|
+
|
|
75
|
+
The plugin leaves the temp root alone, and says why in the report header, when any of these holds:
|
|
76
|
+
|
|
77
|
+
| Condition | Header |
|
|
78
|
+
| --- | --- |
|
|
79
|
+
| Not running on Linux | `shm: off, not Linux` |
|
|
80
|
+
| `TMPDIR`, `TEMP`, or `TMP` is exported | `shm: off, TMPDIR is exported` |
|
|
81
|
+
| `--basetemp` or `PYTEST_DEBUG_TEMPROOT` puts pytest's base directory outside `/dev/shm` | `shm: off, --basetemp is outside /dev/shm` |
|
|
82
|
+
| pytest's `tmpdir` plugin is disabled (`-p no:tmpdir`) | `shm: off, pytest's tmpdir plugin is disabled` |
|
|
83
|
+
| `/dev/shm` is missing, or not writable and searchable | `shm: off, /dev/shm is missing or not writable` |
|
|
84
|
+
| `/dev/shm` is mounted `noexec` (Docker's default), which would break tests that run scripts they write | `shm: off, /dev/shm is mounted noexec` |
|
|
85
|
+
| `/dev/shm` has less free space than `shm_min_free_gib` (Docker's default is 64 MiB) | `shm: off, /dev/shm has 0.1 GiB free, below shm_min_free_gib = 1` |
|
|
86
|
+
|
|
87
|
+
To turn it off explicitly, export `TMPDIR` to the directory you want, or pass `-o shm_min_free_gib=inf`.
|
|
88
|
+
`-p no:shm` works too, but pytest then warns about the unknown `shm_min_free_gib` option if you configured it, and `--strict-config` makes that an error.
|
|
89
|
+
|
|
90
|
+
If you export `TMPDIR=/dev/shm` yourself, the plugin still contains and cleans up temporary files, as long as pytest's base directory is on `/dev/shm` too.
|
|
91
|
+
|
|
92
|
+
## Configuration
|
|
93
|
+
|
|
94
|
+
One ini option sets how much free space `/dev/shm` needs before the plugin uses it:
|
|
95
|
+
|
|
96
|
+
```toml
|
|
97
|
+
[tool.pytest.ini_options]
|
|
98
|
+
shm_min_free_gib = 4
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The default is `1`.
|
|
102
|
+
Set it above your suite's peak usage, which you can watch with `df -h /dev/shm` during a run.
|
|
103
|
+
Override it for one run with `-o shm_min_free_gib=8`.
|
|
104
|
+
|
|
105
|
+
## Caveats
|
|
106
|
+
|
|
107
|
+
- **Only output during the session is contained.** Temporary files created before the session starts (while the initial `conftest.py` files are imported, in `pytest_configure`, or in other plugins' `pytest_sessionstart` hooks) or after it ends (`pytest_terminal_summary`, `pytest_unconfigure`) land directly in `/dev/shm` and stay there until reboot. Create them in fixtures, or remove them yourself.
|
|
108
|
+
- **Caches under the temp root become per-session.** Libraries that cache downloads under `tempfile.gettempdir()` see the contained directory, which the plugin frees after the session. Pin such caches in your root `conftest.py`, where `tempfile.gettempdir()` is still `/dev/shm`. For tiktoken:
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
if "TIKTOKEN_CACHE_DIR" not in os.environ and "DATA_GYM_CACHE_DIR" not in os.environ:
|
|
112
|
+
os.environ["TIKTOKEN_CACHE_DIR"] = str(Path(tempfile.gettempdir()) / "data-gym-cache")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
- **Temp files live on another filesystem.** `os.rename` or `os.replace` from a temporary file into your project fails with `EXDEV`, as it already does wherever `/tmp` is tmpfs.
|
|
116
|
+
- **Paths get longer.** `/dev/shm/pytest-of-<user>/pytest-N/popen-gwN/shm-tmp/tmpXXXXXXXX` is much longer than `/tmp/tmpXXXXXXXX`, which matters for the 107-byte limit on `AF_UNIX` socket paths.
|
|
117
|
+
- **Files use RAM.** Everything a session writes counts against memory until the session ends. Base directories of failing sessions stay until the machine reboots or later failing sessions push them out of pytest's retention of three numbered directories; passing sessions reuse the freed number instead of advancing it.
|
|
118
|
+
- **Plugin autoloading.** With `PYTEST_DISABLE_PLUGIN_AUTOLOAD` set, pass `-p shm` to load the plugin.
|
|
119
|
+
|
|
120
|
+
## Development
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
just install # uv sync --dev
|
|
124
|
+
just test # uv run pytest -n auto
|
|
125
|
+
just lint # ruff, mypy, ty
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The tests run real pytest sessions in subprocesses with `pytester` and inspect what they leave behind on `/dev/shm`.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pytest-shm"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "Put pytest's temporary files on the /dev/shm tmpfs so fsync-heavy suites stop waiting on the disk"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Bas Nijholt", email = "bas@nijho.lt" }
|
|
10
|
+
]
|
|
11
|
+
maintainers = [
|
|
12
|
+
{ name = "Bas Nijholt", email = "bas@nijho.lt" }
|
|
13
|
+
]
|
|
14
|
+
requires-python = ">=3.10"
|
|
15
|
+
keywords = [
|
|
16
|
+
"pytest",
|
|
17
|
+
"tmpfs",
|
|
18
|
+
"shm",
|
|
19
|
+
"fsync",
|
|
20
|
+
"tempfile",
|
|
21
|
+
"xdist",
|
|
22
|
+
"testing",
|
|
23
|
+
"performance",
|
|
24
|
+
]
|
|
25
|
+
classifiers = [
|
|
26
|
+
"Development Status :: 4 - Beta",
|
|
27
|
+
"Framework :: Pytest",
|
|
28
|
+
"Intended Audience :: Developers",
|
|
29
|
+
"License :: OSI Approved :: MIT License",
|
|
30
|
+
"Operating System :: POSIX :: Linux",
|
|
31
|
+
"Programming Language :: Python :: 3",
|
|
32
|
+
"Programming Language :: Python :: 3.10",
|
|
33
|
+
"Programming Language :: Python :: 3.11",
|
|
34
|
+
"Programming Language :: Python :: 3.12",
|
|
35
|
+
"Programming Language :: Python :: 3.13",
|
|
36
|
+
"Programming Language :: Python :: 3.14",
|
|
37
|
+
"Topic :: Software Development :: Testing",
|
|
38
|
+
"Typing :: Typed",
|
|
39
|
+
]
|
|
40
|
+
dependencies = [
|
|
41
|
+
"pytest>=8.4",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
[project.urls]
|
|
45
|
+
Homepage = "https://github.com/basnijholt/pytest-shm"
|
|
46
|
+
Repository = "https://github.com/basnijholt/pytest-shm"
|
|
47
|
+
Documentation = "https://github.com/basnijholt/pytest-shm#readme"
|
|
48
|
+
Issues = "https://github.com/basnijholt/pytest-shm/issues"
|
|
49
|
+
Changelog = "https://github.com/basnijholt/pytest-shm/releases"
|
|
50
|
+
|
|
51
|
+
[project.entry-points.pytest11]
|
|
52
|
+
shm = "pytest_shm.plugin"
|
|
53
|
+
|
|
54
|
+
[build-system]
|
|
55
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
56
|
+
build-backend = "hatchling.build"
|
|
57
|
+
|
|
58
|
+
[tool.hatch.version]
|
|
59
|
+
source = "vcs"
|
|
60
|
+
|
|
61
|
+
[tool.hatch.build.hooks.vcs]
|
|
62
|
+
version-file = "src/pytest_shm/_version.py"
|
|
63
|
+
|
|
64
|
+
[tool.hatch.build.targets.wheel]
|
|
65
|
+
packages = ["src/pytest_shm"]
|
|
66
|
+
|
|
67
|
+
[tool.hatch.build.targets.sdist]
|
|
68
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
target-version = "py310"
|
|
72
|
+
line-length = 100
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint]
|
|
75
|
+
select = ["ALL"]
|
|
76
|
+
ignore = [
|
|
77
|
+
"CPY001", # no copyright headers; LICENSE covers the project
|
|
78
|
+
"D203", # incompatible with D211
|
|
79
|
+
"D213", # incompatible with D212
|
|
80
|
+
"E501", # formatter handles line length
|
|
81
|
+
"COM812", # avoid formatter conflicts
|
|
82
|
+
"ISC001", # avoid formatter conflicts
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
[tool.ruff.lint.per-file-ignores]
|
|
86
|
+
"tests/*" = ["S101", "S108", "PLR2004", "D103", "ARG001"] # relaxed for tests
|
|
87
|
+
|
|
88
|
+
[tool.mypy]
|
|
89
|
+
python_version = "3.10"
|
|
90
|
+
strict = true
|
|
91
|
+
|
|
92
|
+
[[tool.mypy.overrides]]
|
|
93
|
+
module = "pytest_shm._version"
|
|
94
|
+
ignore_missing_imports = true
|
|
95
|
+
|
|
96
|
+
[[tool.mypy.overrides]]
|
|
97
|
+
module = "xdist.*"
|
|
98
|
+
ignore_missing_imports = true
|
|
99
|
+
|
|
100
|
+
[tool.pytest.ini_options]
|
|
101
|
+
testpaths = ["tests"]
|
|
102
|
+
|
|
103
|
+
[tool.ty.environment]
|
|
104
|
+
python-version = "3.10"
|
|
105
|
+
|
|
106
|
+
[tool.ty.src]
|
|
107
|
+
exclude = [
|
|
108
|
+
"src/pytest_shm/_version.py", # Generated at build time
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
[dependency-groups]
|
|
112
|
+
dev = [
|
|
113
|
+
"mypy>=1.19.0",
|
|
114
|
+
"pre-commit>=4.5.0",
|
|
115
|
+
"pytest>=9.0.2",
|
|
116
|
+
"pytest-xdist>=3.8.0",
|
|
117
|
+
"ruff>=0.16.0",
|
|
118
|
+
"ty>=0.0.1a13",
|
|
119
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# file generated by vcs-versioning
|
|
2
|
+
# don't change, don't track in version control
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"__version__",
|
|
7
|
+
"__version_tuple__",
|
|
8
|
+
"version",
|
|
9
|
+
"version_tuple",
|
|
10
|
+
"__commit_id__",
|
|
11
|
+
"commit_id",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
version: str
|
|
15
|
+
__version__: str
|
|
16
|
+
__version_tuple__: tuple[int | str, ...]
|
|
17
|
+
version_tuple: tuple[int | str, ...]
|
|
18
|
+
commit_id: str | None
|
|
19
|
+
__commit_id__: str | None
|
|
20
|
+
|
|
21
|
+
__version__ = version = '0.1.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 1, 0)
|
|
23
|
+
|
|
24
|
+
__commit_id__ = commit_id = None
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Run pytest with its temporary files on the `/dev/shm` tmpfs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Generator, Mapping
|
|
16
|
+
|
|
17
|
+
from xdist.workermanage import WorkerController
|
|
18
|
+
|
|
19
|
+
SHM = Path("/dev/shm") # noqa: S108
|
|
20
|
+
_TEMP_ROOT_VARIABLES = ("TMPDIR", "TEMP", "TMP")
|
|
21
|
+
_MIN_FREE_INI = "shm_min_free_gib"
|
|
22
|
+
_CONTAINED_NAME = "shm-tmp"
|
|
23
|
+
_OWNS_BASETEMP_KEY = "shm_owns_basetemp"
|
|
24
|
+
_OFF_REASON = pytest.StashKey[str]()
|
|
25
|
+
_CONTAINED = pytest.StashKey[bool]()
|
|
26
|
+
_OWNED_BASETEMP = pytest.StashKey[Path]()
|
|
27
|
+
_NOTHING_TO_INSPECT = (
|
|
28
|
+
pytest.ExitCode.OK,
|
|
29
|
+
pytest.ExitCode.NO_TESTS_COLLECTED,
|
|
30
|
+
pytest.ExitCode.USAGE_ERROR,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def off_reason(
|
|
35
|
+
min_free_gib: float,
|
|
36
|
+
environ: Mapping[str, str],
|
|
37
|
+
basetemp: str | None = None,
|
|
38
|
+
) -> str | None:
|
|
39
|
+
"""Return why the temp root should stay where it is, or `None` to move it to `/dev/shm`.
|
|
40
|
+
|
|
41
|
+
`basetemp` is the caller's `--basetemp`, which, like `PYTEST_DEBUG_TEMPROOT`,
|
|
42
|
+
decides where pytest's base directory goes regardless of the temp root.
|
|
43
|
+
"""
|
|
44
|
+
if sys.platform != "linux":
|
|
45
|
+
return "not Linux"
|
|
46
|
+
for name in _TEMP_ROOT_VARIABLES:
|
|
47
|
+
if name in environ:
|
|
48
|
+
return f"{name} is exported"
|
|
49
|
+
base_source, base = (
|
|
50
|
+
("--basetemp", basetemp)
|
|
51
|
+
if basetemp
|
|
52
|
+
else ("PYTEST_DEBUG_TEMPROOT", environ.get("PYTEST_DEBUG_TEMPROOT"))
|
|
53
|
+
)
|
|
54
|
+
if base and not Path(base).resolve().is_relative_to(SHM.resolve()):
|
|
55
|
+
return f"{base_source} is outside {SHM}"
|
|
56
|
+
return _shm_off_reason(min_free_gib)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _shm_off_reason(min_free_gib: float) -> str | None:
|
|
60
|
+
"""Return why `/dev/shm` itself cannot hold the session's files, or `None`."""
|
|
61
|
+
if not SHM.is_dir() or not os.access(SHM, os.W_OK | os.X_OK):
|
|
62
|
+
return f"{SHM} is missing or not writable"
|
|
63
|
+
if os.statvfs(SHM).f_flag & os.ST_NOEXEC:
|
|
64
|
+
return f"{SHM} is mounted noexec"
|
|
65
|
+
free_gib = shutil.disk_usage(SHM).free / 2**30
|
|
66
|
+
if free_gib < min_free_gib:
|
|
67
|
+
return f"{SHM} has {free_gib:.1f} GiB free, below {_MIN_FREE_INI} = {min_free_gib:g}"
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def pytest_addoption(parser: pytest.Parser) -> None:
|
|
72
|
+
"""Register the free-space threshold."""
|
|
73
|
+
parser.addini(
|
|
74
|
+
_MIN_FREE_INI,
|
|
75
|
+
f"Minimum free GiB {SHM} needs before temporary files move there (default: 1).",
|
|
76
|
+
type="float",
|
|
77
|
+
default=1.0,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@pytest.hookimpl(tryfirst=True)
|
|
82
|
+
def pytest_load_initial_conftests(early_config: pytest.Config) -> None:
|
|
83
|
+
"""Point the temp root at `/dev/shm` before any `conftest.py` can read it."""
|
|
84
|
+
reason = off_reason(
|
|
85
|
+
early_config.getini(_MIN_FREE_INI),
|
|
86
|
+
os.environ,
|
|
87
|
+
# pytest's tmpdir plugin registers --basetemp, so it is missing when that plugin is.
|
|
88
|
+
getattr(early_config.known_args_namespace, "basetemp", None),
|
|
89
|
+
)
|
|
90
|
+
if reason is None and early_config.pluginmanager.is_blocked("tmpdir"):
|
|
91
|
+
reason = "pytest's tmpdir plugin is disabled"
|
|
92
|
+
if reason is not None:
|
|
93
|
+
early_config.stash[_OFF_REASON] = reason
|
|
94
|
+
return
|
|
95
|
+
previous_tempdir = tempfile.tempdir
|
|
96
|
+
os.environ["TMPDIR"] = str(SHM)
|
|
97
|
+
tempfile.tempdir = None
|
|
98
|
+
|
|
99
|
+
def restore() -> None:
|
|
100
|
+
# pytest runs cleanups even when the session never configures, such as on a usage error.
|
|
101
|
+
os.environ.pop("TMPDIR", None)
|
|
102
|
+
tempfile.tempdir = previous_tempdir
|
|
103
|
+
|
|
104
|
+
early_config.add_cleanup(restore)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def pytest_report_header(config: pytest.Config) -> str:
|
|
108
|
+
"""Say where temporary files go, or why they stay put."""
|
|
109
|
+
temp_root = tempfile.gettempdir()
|
|
110
|
+
if Path(temp_root) == SHM:
|
|
111
|
+
return f"shm: temp root {SHM}"
|
|
112
|
+
return f"shm: off, {config.stash.get(_OFF_REASON, f'temp root is {temp_root}')}"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _pytest_chose_basetemp(config: pytest.Config) -> bool:
|
|
116
|
+
"""Tell whether pytest picked the base directory rather than the caller."""
|
|
117
|
+
workerinput = getattr(config, "workerinput", None)
|
|
118
|
+
if workerinput is not None:
|
|
119
|
+
return bool(workerinput.get(_OWNS_BASETEMP_KEY, False))
|
|
120
|
+
return config.option.basetemp is None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@pytest.hookimpl(optionalhook=True)
|
|
124
|
+
def pytest_configure_node(node: WorkerController) -> None:
|
|
125
|
+
"""Tell each xdist worker whether its base directory is pytest's own or the caller's.
|
|
126
|
+
|
|
127
|
+
xdist hands every worker its directory as `--basetemp`, so only the controller
|
|
128
|
+
still knows whether the caller chose one, which must outlive a passing session.
|
|
129
|
+
"""
|
|
130
|
+
node.workerinput[_OWNS_BASETEMP_KEY] = node.config.option.basetemp is None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@pytest.hookimpl(wrapper=True)
|
|
134
|
+
def pytest_sessionstart(session: pytest.Session) -> Generator[None]:
|
|
135
|
+
"""Keep bare `tempfile` output inside pytest's base directory on `/dev/shm`.
|
|
136
|
+
|
|
137
|
+
Tests and the code they drive often call `tempfile.mkdtemp()` without removing
|
|
138
|
+
the result. On disk that only clutters `/tmp`, but on tmpfs it would hold memory
|
|
139
|
+
until reboot, so from collection on it joins the `tmp_path` directories that
|
|
140
|
+
`pytest_sessionfinish` frees.
|
|
141
|
+
|
|
142
|
+
This runs after every other `pytest_sessionstart`, once xdist has started its
|
|
143
|
+
workers, so they inherit `/dev/shm` itself rather than this process's directory.
|
|
144
|
+
Workers never switch the temp root, so this checks where it is rather than
|
|
145
|
+
whether this process moved it. A worker xdist starts later to replace a crashed
|
|
146
|
+
one inherits the controller's directory instead, and its files are freed with it.
|
|
147
|
+
"""
|
|
148
|
+
yield
|
|
149
|
+
config = session.config
|
|
150
|
+
# pytest's tmpdir plugin sets this in pytest_configure, and xdist reads it the same way.
|
|
151
|
+
factory: pytest.TempPathFactory | None = getattr(config, "_tmp_path_factory", None)
|
|
152
|
+
if factory is None or Path(tempfile.gettempdir()) != SHM:
|
|
153
|
+
return
|
|
154
|
+
basetemp = factory.getbasetemp()
|
|
155
|
+
if not basetemp.is_relative_to(SHM.resolve()):
|
|
156
|
+
# Containing would move files the caller sent to /dev/shm onto disk.
|
|
157
|
+
return
|
|
158
|
+
if _pytest_chose_basetemp(config):
|
|
159
|
+
config.stash[_OWNED_BASETEMP] = basetemp
|
|
160
|
+
contained = basetemp / _CONTAINED_NAME
|
|
161
|
+
contained.mkdir()
|
|
162
|
+
os.environ["TMPDIR"] = str(contained)
|
|
163
|
+
tempfile.tempdir = str(contained)
|
|
164
|
+
config.stash[_CONTAINED] = True
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@pytest.hookimpl(trylast=True)
|
|
168
|
+
def pytest_sessionfinish(session: pytest.Session, exitstatus: int | pytest.ExitCode) -> None:
|
|
169
|
+
"""Hand back `/dev/shm`, and free the base directory of a session nobody needs to inspect.
|
|
170
|
+
|
|
171
|
+
pytest keeps the last three sessions' directories, which on tmpfs pins memory
|
|
172
|
+
until they are pruned. A session that passed, collected no tests, or stopped at a
|
|
173
|
+
usage error leaves nothing worth keeping. Each xdist worker owns its own base
|
|
174
|
+
directory, so a failing session keeps only the workers that saw a failure.
|
|
175
|
+
Deleting per test is not an option: pytest then reuses the freed names, and
|
|
176
|
+
caches keyed by path hand the next test the previous one's state.
|
|
177
|
+
"""
|
|
178
|
+
config = session.config
|
|
179
|
+
if config.stash.get(_CONTAINED, False):
|
|
180
|
+
os.environ["TMPDIR"] = str(SHM)
|
|
181
|
+
tempfile.tempdir = None
|
|
182
|
+
basetemp = config.stash.get(_OWNED_BASETEMP, None)
|
|
183
|
+
if basetemp is not None and exitstatus in _NOTHING_TO_INSPECT:
|
|
184
|
+
shutil.rmtree(basetemp, ignore_errors=True)
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Tests for pytest-shm."""
|
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
"""Behavior of the pytest-shm plugin, observed through real pytest sessions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from pytest_shm.plugin import SHM, off_reason
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Iterator
|
|
19
|
+
|
|
20
|
+
_SHM_OFF = off_reason(1, {})
|
|
21
|
+
needs_shm = pytest.mark.skipif(_SHM_OFF is not None, reason=f"{SHM} is unusable: {_SHM_OFF}")
|
|
22
|
+
|
|
23
|
+
_TEMP_ROOT_IS_NOT_SHM = """
|
|
24
|
+
import tempfile
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
def test_temp_root():
|
|
28
|
+
temp_root = Path(tempfile.gettempdir()).resolve()
|
|
29
|
+
assert not temp_root.is_relative_to(Path("/dev/shm").resolve())
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@pytest.fixture
|
|
34
|
+
def temproot(pytester: pytest.Pytester, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
|
|
35
|
+
"""Give inner sessions no exported temp root and their own pytest temp root on /dev/shm.
|
|
36
|
+
|
|
37
|
+
Depending on `pytester` makes this override the `PYTEST_DEBUG_TEMPROOT` it sets.
|
|
38
|
+
"""
|
|
39
|
+
for name in ("TMPDIR", "TEMP", "TMP"):
|
|
40
|
+
monkeypatch.delenv(name, raising=False)
|
|
41
|
+
root = Path(tempfile.mkdtemp(dir=SHM, prefix="pytest-shm-test-"))
|
|
42
|
+
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(root))
|
|
43
|
+
yield root
|
|
44
|
+
shutil.rmtree(root)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture
|
|
48
|
+
def disk_dir() -> Iterator[Path]:
|
|
49
|
+
"""Give a directory outside /dev/shm."""
|
|
50
|
+
path = Path(tempfile.mkdtemp(dir="/var/tmp", prefix="pytest-shm-test-"))
|
|
51
|
+
yield path
|
|
52
|
+
shutil.rmtree(path)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run_pytest(pytester: pytest.Pytester, *args: str) -> pytest.RunResult:
|
|
56
|
+
"""Run pytest in a subprocess without the `--basetemp` that `runpytest_subprocess` forces."""
|
|
57
|
+
return pytester.run(sys.executable, "-m", "pytest", "-p", "no:cacheprovider", *args, timeout=60)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_plugin_is_registered(pytestconfig: pytest.Config) -> None:
|
|
61
|
+
assert pytestconfig.pluginmanager.has_plugin("shm")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@needs_shm
|
|
65
|
+
def test_moves_the_temp_root_to_shm_before_conftests_load(
|
|
66
|
+
pytester: pytest.Pytester, temproot: Path, monkeypatch: pytest.MonkeyPatch
|
|
67
|
+
) -> None:
|
|
68
|
+
# Let pytest derive its base directory from the switched TMPDIR, as it does for users.
|
|
69
|
+
monkeypatch.delenv("PYTEST_DEBUG_TEMPROOT")
|
|
70
|
+
pytester.makeconftest(
|
|
71
|
+
"""
|
|
72
|
+
import tempfile
|
|
73
|
+
|
|
74
|
+
IMPORT_TIME_TEMP_ROOT = tempfile.gettempdir()
|
|
75
|
+
|
|
76
|
+
def pytest_report_header():
|
|
77
|
+
return f"conftest saw {IMPORT_TIME_TEMP_ROOT}"
|
|
78
|
+
"""
|
|
79
|
+
)
|
|
80
|
+
pytester.makepyfile(
|
|
81
|
+
"""
|
|
82
|
+
import tempfile
|
|
83
|
+
from pathlib import Path
|
|
84
|
+
|
|
85
|
+
def test_temp_root(tmp_path_factory):
|
|
86
|
+
shm = Path("/dev/shm").resolve()
|
|
87
|
+
assert Path(tempfile.gettempdir()).is_relative_to(shm)
|
|
88
|
+
assert tmp_path_factory.getbasetemp().is_relative_to(shm)
|
|
89
|
+
"""
|
|
90
|
+
)
|
|
91
|
+
result = run_pytest(pytester)
|
|
92
|
+
result.assert_outcomes(passed=1)
|
|
93
|
+
result.stdout.fnmatch_lines(["shm: temp root /dev/shm", "conftest saw /dev/shm"])
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@needs_shm
|
|
97
|
+
def test_exported_temp_root_wins(
|
|
98
|
+
pytester: pytest.Pytester, temproot: Path, monkeypatch: pytest.MonkeyPatch
|
|
99
|
+
) -> None:
|
|
100
|
+
chosen = pytester.mkdir("chosen")
|
|
101
|
+
monkeypatch.setenv("TMPDIR", str(chosen))
|
|
102
|
+
pytester.makepyfile(
|
|
103
|
+
f"""
|
|
104
|
+
import tempfile
|
|
105
|
+
|
|
106
|
+
def test_temp_root():
|
|
107
|
+
assert tempfile.gettempdir() == {str(chosen)!r}
|
|
108
|
+
"""
|
|
109
|
+
)
|
|
110
|
+
result = run_pytest(pytester)
|
|
111
|
+
result.assert_outcomes(passed=1)
|
|
112
|
+
result.stdout.fnmatch_lines(["shm: off, TMPDIR is exported"])
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@needs_shm
|
|
116
|
+
def test_base_directory_chosen_outside_shm_leaves_the_temp_root_alone(
|
|
117
|
+
pytester: pytest.Pytester, temproot: Path, disk_dir: Path
|
|
118
|
+
) -> None:
|
|
119
|
+
pytester.makepyfile(_TEMP_ROOT_IS_NOT_SHM)
|
|
120
|
+
result = run_pytest(pytester, f"--basetemp={disk_dir / 'basetemp'}")
|
|
121
|
+
result.assert_outcomes(passed=1)
|
|
122
|
+
result.stdout.fnmatch_lines(["shm: off, --basetemp is outside /dev/shm"])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@needs_shm
|
|
126
|
+
def test_debug_temp_root_outside_shm_leaves_the_temp_root_alone(
|
|
127
|
+
pytester: pytest.Pytester, temproot: Path, disk_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
128
|
+
) -> None:
|
|
129
|
+
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(disk_dir))
|
|
130
|
+
pytester.makepyfile(_TEMP_ROOT_IS_NOT_SHM)
|
|
131
|
+
result = run_pytest(pytester)
|
|
132
|
+
result.assert_outcomes(passed=1)
|
|
133
|
+
result.stdout.fnmatch_lines(["shm: off, PYTEST_DEBUG_TEMPROOT is outside /dev/shm"])
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@needs_shm
|
|
137
|
+
def test_too_little_free_space_leaves_the_temp_root_alone(
|
|
138
|
+
pytester: pytest.Pytester, temproot: Path
|
|
139
|
+
) -> None:
|
|
140
|
+
pytester.makepyfile(_TEMP_ROOT_IS_NOT_SHM)
|
|
141
|
+
result = run_pytest(pytester, "-o", "shm_min_free_gib=1e9")
|
|
142
|
+
result.assert_outcomes(passed=1)
|
|
143
|
+
result.stdout.fnmatch_lines(
|
|
144
|
+
["shm: off, /dev/shm has * GiB free, below shm_min_free_gib = 1e+09"]
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@needs_shm
|
|
149
|
+
@pytest.mark.skipif(
|
|
150
|
+
int(pytest.__version__.partition(".")[0]) < 9, reason="native [tool.pytest] needs pytest 9"
|
|
151
|
+
)
|
|
152
|
+
def test_threshold_accepts_a_native_toml_number(pytester: pytest.Pytester, temproot: Path) -> None:
|
|
153
|
+
pytester.makepyprojecttoml("[tool.pytest]\nshm_min_free_gib = 1e9\n")
|
|
154
|
+
pytester.makepyfile(_TEMP_ROOT_IS_NOT_SHM)
|
|
155
|
+
result = run_pytest(pytester)
|
|
156
|
+
result.assert_outcomes(passed=1)
|
|
157
|
+
result.stdout.fnmatch_lines(
|
|
158
|
+
["shm: off, /dev/shm has * GiB free, below shm_min_free_gib = 1e+09"]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@needs_shm
|
|
163
|
+
def test_disabled_plugin_leaves_the_temp_root_alone(
|
|
164
|
+
pytester: pytest.Pytester, temproot: Path
|
|
165
|
+
) -> None:
|
|
166
|
+
pytester.makepyfile(_TEMP_ROOT_IS_NOT_SHM)
|
|
167
|
+
result = run_pytest(pytester, "-p", "no:shm")
|
|
168
|
+
result.assert_outcomes(passed=1)
|
|
169
|
+
result.stdout.no_fnmatch_line("shm:*")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_disabled_tmpdir_plugin_leaves_sessions_working(pytester: pytest.Pytester) -> None:
|
|
173
|
+
pytester.makepyfile("def test_pass():\n pass\n")
|
|
174
|
+
result = run_pytest(pytester, "-p", "no:tmpdir")
|
|
175
|
+
result.assert_outcomes(passed=1)
|
|
176
|
+
result.stdout.fnmatch_lines(["shm: off, *"])
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@needs_shm
|
|
180
|
+
def test_disabled_tmpdir_plugin_turns_the_plugin_off(
|
|
181
|
+
pytester: pytest.Pytester, temproot: Path
|
|
182
|
+
) -> None:
|
|
183
|
+
pytester.makepyfile(_TEMP_ROOT_IS_NOT_SHM)
|
|
184
|
+
result = run_pytest(pytester, "-p", "no:tmpdir")
|
|
185
|
+
result.assert_outcomes(passed=1)
|
|
186
|
+
result.stdout.fnmatch_lines(["shm: off, pytest's tmpdir plugin is disabled"])
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@needs_shm
|
|
190
|
+
def test_in_process_session_restores_the_callers_environment(
|
|
191
|
+
pytester: pytest.Pytester, temproot: Path, monkeypatch: pytest.MonkeyPatch, disk_dir: Path
|
|
192
|
+
) -> None:
|
|
193
|
+
monkeypatch.setattr(tempfile, "tempdir", str(disk_dir))
|
|
194
|
+
pytester.makepyfile("def test_pass():\n pass\n")
|
|
195
|
+
pytester.runpytest_inprocess().assert_outcomes(passed=1)
|
|
196
|
+
assert "TMPDIR" not in os.environ
|
|
197
|
+
assert tempfile.tempdir == str(disk_dir)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
@needs_shm
|
|
201
|
+
def test_in_process_usage_error_restores_the_environment(
|
|
202
|
+
pytester: pytest.Pytester, temproot: Path, monkeypatch: pytest.MonkeyPatch
|
|
203
|
+
) -> None:
|
|
204
|
+
monkeypatch.setattr(tempfile, "tempdir", None)
|
|
205
|
+
result = pytester.runpytest_inprocess("--no-such-flag")
|
|
206
|
+
assert result.ret == pytest.ExitCode.USAGE_ERROR
|
|
207
|
+
assert "TMPDIR" not in os.environ
|
|
208
|
+
assert not Path(tempfile.gettempdir()).is_relative_to(SHM)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@needs_shm
|
|
212
|
+
def test_in_process_session_hands_back_an_exported_shm_temp_root(
|
|
213
|
+
pytester: pytest.Pytester, temproot: Path, monkeypatch: pytest.MonkeyPatch
|
|
214
|
+
) -> None:
|
|
215
|
+
monkeypatch.setenv("TMPDIR", str(SHM))
|
|
216
|
+
monkeypatch.setattr(tempfile, "tempdir", None)
|
|
217
|
+
pytester.makepyfile(
|
|
218
|
+
"""
|
|
219
|
+
import tempfile
|
|
220
|
+
|
|
221
|
+
def test_contained():
|
|
222
|
+
assert tempfile.gettempdir() != "/dev/shm"
|
|
223
|
+
"""
|
|
224
|
+
)
|
|
225
|
+
pytester.runpytest_inprocess().assert_outcomes(passed=1)
|
|
226
|
+
assert os.environ["TMPDIR"] == str(SHM)
|
|
227
|
+
assert tempfile.tempdir is None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@needs_shm
|
|
231
|
+
def test_noexec_shm_is_refused(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
232
|
+
monkeypatch.setattr(os, "statvfs", lambda _path: SimpleNamespace(f_flag=os.ST_NOEXEC))
|
|
233
|
+
assert off_reason(0, {}) == "/dev/shm is mounted noexec"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
@pytest.mark.skipif(sys.platform == "linux", reason="checks the report outside Linux")
|
|
237
|
+
def test_stays_off_outside_linux(pytester: pytest.Pytester) -> None:
|
|
238
|
+
pytester.makepyfile("def test_pass():\n pass\n")
|
|
239
|
+
result = run_pytest(pytester)
|
|
240
|
+
result.assert_outcomes(passed=1)
|
|
241
|
+
result.stdout.fnmatch_lines(["shm: off, not Linux"])
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
@needs_shm
|
|
245
|
+
def test_bare_tempfile_output_lands_in_the_base_directory_from_collection_on(
|
|
246
|
+
pytester: pytest.Pytester, temproot: Path
|
|
247
|
+
) -> None:
|
|
248
|
+
pytester.makepyfile(
|
|
249
|
+
"""
|
|
250
|
+
import os
|
|
251
|
+
import tempfile
|
|
252
|
+
from pathlib import Path
|
|
253
|
+
|
|
254
|
+
COLLECTION_TIME_DIRECTORY = Path(tempfile.mkdtemp())
|
|
255
|
+
|
|
256
|
+
def test_contained(tmp_path_factory):
|
|
257
|
+
contained = tmp_path_factory.getbasetemp() / "shm-tmp"
|
|
258
|
+
assert COLLECTION_TIME_DIRECTORY.parent == contained
|
|
259
|
+
assert tempfile.gettempdir() == os.environ["TMPDIR"] == str(contained)
|
|
260
|
+
assert Path(tempfile.mkdtemp()).parent == contained
|
|
261
|
+
"""
|
|
262
|
+
)
|
|
263
|
+
run_pytest(pytester).assert_outcomes(passed=1)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@needs_shm
|
|
267
|
+
@pytest.mark.parametrize(
|
|
268
|
+
("statement", "outcome"),
|
|
269
|
+
[("pass", "passed"), ("assert False", "failed")],
|
|
270
|
+
ids=["passing", "failing"],
|
|
271
|
+
)
|
|
272
|
+
def test_only_a_failing_session_keeps_its_base_directory(
|
|
273
|
+
pytester: pytest.Pytester, temproot: Path, statement: str, outcome: str
|
|
274
|
+
) -> None:
|
|
275
|
+
pytester.makepyfile(
|
|
276
|
+
f"""
|
|
277
|
+
import tempfile
|
|
278
|
+
from pathlib import Path
|
|
279
|
+
|
|
280
|
+
def test_write(tmp_path):
|
|
281
|
+
(tmp_path / "state").write_text("x")
|
|
282
|
+
Path(tempfile.mkdtemp(), "state").write_text("x")
|
|
283
|
+
{statement}
|
|
284
|
+
"""
|
|
285
|
+
)
|
|
286
|
+
run_pytest(pytester).assert_outcomes(**{outcome: 1})
|
|
287
|
+
numbered = list(temproot.glob("pytest-of-*/pytest-[0-9]*"))
|
|
288
|
+
if outcome == "passed":
|
|
289
|
+
assert numbered == []
|
|
290
|
+
else:
|
|
291
|
+
(basetemp,) = numbered
|
|
292
|
+
assert list(basetemp.glob("shm-tmp/tmp*/state"))
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@needs_shm
|
|
296
|
+
@pytest.mark.parametrize("workers", [(), ("-n", "2")], ids=["serial", "xdist"])
|
|
297
|
+
def test_explicit_basetemp_survives_a_passing_session(
|
|
298
|
+
pytester: pytest.Pytester, temproot: Path, workers: tuple[str, ...]
|
|
299
|
+
) -> None:
|
|
300
|
+
pytester.makepyfile("def test_write(tmp_path):\n (tmp_path / 'kept').write_text('x')\n")
|
|
301
|
+
basetemp = temproot / "chosen"
|
|
302
|
+
run_pytest(pytester, f"--basetemp={basetemp}", *workers).assert_outcomes(passed=1)
|
|
303
|
+
assert list(basetemp.rglob("kept"))
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@needs_shm
|
|
307
|
+
def test_exported_shm_temp_root_with_base_directory_on_disk_is_left_alone(
|
|
308
|
+
pytester: pytest.Pytester, temproot: Path, disk_dir: Path, monkeypatch: pytest.MonkeyPatch
|
|
309
|
+
) -> None:
|
|
310
|
+
# Containing would move files the caller sent to /dev/shm onto disk.
|
|
311
|
+
monkeypatch.setenv("TMPDIR", str(SHM))
|
|
312
|
+
monkeypatch.setenv("PYTEST_DEBUG_TEMPROOT", str(disk_dir))
|
|
313
|
+
pytester.makepyfile(
|
|
314
|
+
"""
|
|
315
|
+
import tempfile
|
|
316
|
+
|
|
317
|
+
def test_write(tmp_path):
|
|
318
|
+
assert tempfile.gettempdir() == "/dev/shm"
|
|
319
|
+
(tmp_path / "kept").write_text("x")
|
|
320
|
+
"""
|
|
321
|
+
)
|
|
322
|
+
run_pytest(pytester).assert_outcomes(passed=1)
|
|
323
|
+
assert list(disk_dir.glob("pytest-of-*/pytest-[0-9]*/test_write0/kept"))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
@needs_shm
|
|
327
|
+
def test_exported_shm_temp_root_without_tmpdir_plugin_still_runs(
|
|
328
|
+
pytester: pytest.Pytester, temproot: Path, monkeypatch: pytest.MonkeyPatch
|
|
329
|
+
) -> None:
|
|
330
|
+
monkeypatch.setenv("TMPDIR", str(SHM))
|
|
331
|
+
pytester.makepyfile("def test_pass():\n pass\n")
|
|
332
|
+
run_pytest(pytester, "-p", "no:tmpdir").assert_outcomes(passed=1)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
@needs_shm
|
|
336
|
+
@pytest.mark.parametrize(
|
|
337
|
+
("args", "exit_code"),
|
|
338
|
+
[(("-k", "no_such_test"), 5), (("no_such_path.py",), 4)],
|
|
339
|
+
ids=["no-tests-collected", "usage-error"],
|
|
340
|
+
)
|
|
341
|
+
def test_session_that_runs_no_tests_leaves_nothing_behind(
|
|
342
|
+
pytester: pytest.Pytester, temproot: Path, args: tuple[str, ...], exit_code: int
|
|
343
|
+
) -> None:
|
|
344
|
+
pytester.makepyfile("def test_pass():\n pass\n")
|
|
345
|
+
assert run_pytest(pytester, *args).ret == exit_code
|
|
346
|
+
assert list(temproot.glob("pytest-of-*/pytest-[0-9]*")) == []
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@needs_shm
|
|
350
|
+
def test_base_directory_is_freed_after_session_fixtures_finish_with_it(
|
|
351
|
+
pytester: pytest.Pytester, temproot: Path
|
|
352
|
+
) -> None:
|
|
353
|
+
# pytest.exit skips the last test's teardown, so pytest tears session fixtures down
|
|
354
|
+
# in pytest_sessionfinish, which must run before the base directory disappears.
|
|
355
|
+
pytester.makepyfile(
|
|
356
|
+
"""
|
|
357
|
+
import pytest
|
|
358
|
+
|
|
359
|
+
@pytest.fixture(scope="session")
|
|
360
|
+
def state(tmp_path_factory):
|
|
361
|
+
path = tmp_path_factory.mktemp("state")
|
|
362
|
+
yield path
|
|
363
|
+
(path / "closed").write_text("x")
|
|
364
|
+
|
|
365
|
+
def test_stop(state):
|
|
366
|
+
pytest.exit("done", returncode=0)
|
|
367
|
+
"""
|
|
368
|
+
)
|
|
369
|
+
result = run_pytest(pytester)
|
|
370
|
+
assert result.ret == 0, result.stdout.str()
|
|
371
|
+
result.stdout.no_fnmatch_line("*Error*")
|
|
372
|
+
assert list(temproot.glob("pytest-of-*/pytest-[0-9]*")) == []
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
@needs_shm
|
|
376
|
+
def test_passing_xdist_session_leaves_nothing_behind(
|
|
377
|
+
pytester: pytest.Pytester, temproot: Path
|
|
378
|
+
) -> None:
|
|
379
|
+
pytester.makepyfile("def test_write(tmp_path):\n (tmp_path / 'state').write_text('x')\n")
|
|
380
|
+
run_pytest(pytester, "-n", "2", "--dist", "each").assert_outcomes(passed=2)
|
|
381
|
+
assert list(temproot.glob("pytest-of-*/pytest-[0-9]*")) == []
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@needs_shm
|
|
385
|
+
def test_xdist_keeps_only_the_failing_workers_directory(
|
|
386
|
+
pytester: pytest.Pytester, temproot: Path
|
|
387
|
+
) -> None:
|
|
388
|
+
pytester.makepyfile(
|
|
389
|
+
"""
|
|
390
|
+
import os
|
|
391
|
+
|
|
392
|
+
def test_fails_on_gw1():
|
|
393
|
+
assert os.environ["PYTEST_XDIST_WORKER"] != "gw1"
|
|
394
|
+
"""
|
|
395
|
+
)
|
|
396
|
+
run_pytest(pytester, "-n", "2", "--dist", "each").assert_outcomes(passed=1, failed=1)
|
|
397
|
+
(basetemp,) = temproot.glob("pytest-of-*/pytest-[0-9]*")
|
|
398
|
+
assert sorted(path.name for path in basetemp.iterdir()) == ["popen-gw1", "shm-tmp"]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def test_loads_without_xdist(pytester: pytest.Pytester) -> None:
|
|
402
|
+
pytester.makepyfile("def test_pass():\n pass\n")
|
|
403
|
+
run_pytest(pytester, "-p", "no:xdist").assert_outcomes(passed=1)
|