pytest-shm 0.1.0__py3-none-any.whl

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/__init__.py ADDED
@@ -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__"]
pytest_shm/_version.py ADDED
@@ -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
pytest_shm/plugin.py ADDED
@@ -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)
pytest_shm/py.typed ADDED
File without changes
@@ -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,9 @@
1
+ pytest_shm/__init__.py,sha256=HFojLqbNQUiHn5wwbp0TxTOVDcDEwqPF2BZ8ihmtTA0,261
2
+ pytest_shm/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
3
+ pytest_shm/plugin.py,sha256=lu8mMx2D25xxJIfcixf3KrUkP2hVB5OSmda88Lf_6_Q,7246
4
+ pytest_shm/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ pytest_shm-0.1.0.dist-info/METADATA,sha256=S07Jy8qjYEEo4Dyxid2MG1RDaYRY9IPQJ3_pwc8c5GE,8700
6
+ pytest_shm-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
7
+ pytest_shm-0.1.0.dist-info/entry_points.txt,sha256=kbngM5aH-YhfKRpDiwj-kd97Qns59THCsNzoZh1Xh9k,35
8
+ pytest_shm-0.1.0.dist-info/licenses/LICENSE,sha256=Z-jViiDEIKskSQzvdihxxQ5Z25DDxXFdiPwx4ZK2Jho,1068
9
+ pytest_shm-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [pytest11]
2
+ shm = pytest_shm.plugin
@@ -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.