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.
@@ -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/
@@ -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
+ [![PyPI](https://img.shields.io/pypi/v/pytest-shm)](https://pypi.org/project/pytest-shm/)
35
+ [![Python](https://img.shields.io/pypi/pyversions/pytest-shm)](https://pypi.org/project/pytest-shm/)
36
+ [![License](https://img.shields.io/github/license/basnijholt/pytest-shm)](LICENSE)
37
+ [![CI](https://github.com/basnijholt/pytest-shm/actions/workflows/ci.yml/badge.svg)](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
+ [![PyPI](https://img.shields.io/pypi/v/pytest-shm)](https://pypi.org/project/pytest-shm/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/pytest-shm)](https://pypi.org/project/pytest-shm/)
5
+ [![License](https://img.shields.io/github/license/basnijholt/pytest-shm)](LICENSE)
6
+ [![CI](https://github.com/basnijholt/pytest-shm/actions/workflows/ci.yml/badge.svg)](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,9 @@
1
+ """Put pytest's temporary files on the `/dev/shm` tmpfs."""
2
+
3
+ try:
4
+ from pytest_shm._version import __version__, __version_tuple__
5
+ except ImportError:
6
+ __version__ = "0.0.0"
7
+ __version_tuple__ = (0, 0, 0)
8
+
9
+ __all__ = ["__version__", "__version_tuple__"]
@@ -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,3 @@
1
+ """Shared test configuration."""
2
+
3
+ pytest_plugins = ["pytester"]
@@ -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)