cot.tmppath 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. cot_tmppath-0.3.0/CHANGELOG.md +25 -0
  2. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/PKG-INFO +1 -1
  3. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/core.md +9 -7
  4. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/goals.md +3 -3
  5. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/pytest-replacement.md +24 -26
  6. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/_api.py +34 -7
  7. cot_tmppath-0.3.0/src/cot/tmppath/_owner.py +141 -0
  8. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/overtake_pytest.py +45 -57
  9. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_concurrency.py +15 -0
  10. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_overtake_pytest.py +54 -31
  11. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_retention.py +12 -0
  12. cot_tmppath-0.2.0/CHANGELOG.md +0 -13
  13. cot_tmppath-0.2.0/src/cot/tmppath/_owner.py +0 -92
  14. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/dependabot.yml +0 -0
  15. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/workflows/ci.yml +0 -0
  16. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/workflows/release-proposal.yml +0 -0
  17. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/workflows/release-tag.yml +0 -0
  18. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/workflows/release.yml +0 -0
  19. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.gitignore +0 -0
  20. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.pre-commit-config.yaml +0 -0
  21. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/LICENSE +0 -0
  22. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/README.md +0 -0
  23. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/benchmarks/test_bench.py +0 -0
  24. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/changelog.d/README.md +0 -0
  25. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/changelog.d/template.md +0 -0
  26. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/assessment.md +0 -0
  27. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/releasing.md +0 -0
  28. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/research.md +0 -0
  29. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/pyproject.toml +0 -0
  30. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/__init__.py +0 -0
  31. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/__main__.py +0 -0
  32. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/_fs.py +0 -0
  33. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/py.typed +0 -0
  34. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/conftest.py +0 -0
  35. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_cli.py +0 -0
  36. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_core.py +0 -0
  37. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_hardening.py +0 -0
  38. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_layout.py +0 -0
  39. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_pytest_replacement.py +0 -0
  40. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_speed.py +0 -0
  41. {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/uv.lock +0 -0
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ <!-- towncrier release notes start -->
4
+
5
+ ## 0.3.0 (2026-10-08)
6
+
7
+ ### Added
8
+
9
+ - The pytest binding replaces pytest's basetemp handling as a whole: `getbasetemp()` is the run folder (an xdist worker's own folder in the run), `tmp_path` and `mktemp()` folders are made inside it, and `config._tmp_path_factory` is the cot.tmppath factory. `--basetemp` names a root that holds the runs; it is reused when cot.tmppath made it, so repeated `pytester.runpytest()` calls work, and a folder cot.tmppath did not make is refused and never touched. New: `Root.ensure()`, and `Run.item(name, process=...)`.
10
+
11
+ ## 0.2.1 (2026-10-07)
12
+
13
+ ### Fixed
14
+
15
+ - A crashed run whose pid was reused by another process is collected: holder files record the process start time on Linux and Windows. Before, the run counted as alive until that other process ended. macOS still relies on the pid alone.
16
+
17
+ ## 0.2.0 (2026-10-07)
18
+
19
+ ### Removed
20
+
21
+ - The pytest plugin no longer provides the `py.path` fixtures `tmpdir` and `tmpdir_factory`; use `tmp_path` and `tmp_path_factory`.
22
+
23
+ ### Added
24
+
25
+ - Build the core: `Root`, `Run`, the flat and pytest layouts, retention, pruning, liveness and atomic placement now work instead of raising `NotImplementedError`, so `-p cot.tmppath.overtake_pytest` provides working `tmp_path` and `tmp_path_factory` fixtures.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cot.tmppath
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Hardened, fast management of related temporary folders
5
5
  Project-URL: Source, https://github.com/cogs-of-testing/cot.tmppath
6
6
  Author-email: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
@@ -16,7 +16,8 @@ What `src/cot/tmppath/_api.py` does to keep the goals in
16
16
  .cot-trash-{pid}-{token}/ removed items of one process, until it closes
17
17
  test_foo/ items, side by side
18
18
  test_foo-1/
19
- gw0/ process folders
19
+ gw0/ process folders (one per xdist worker)
20
+ test_bar/ items made with process="gw0"
20
21
  ```
21
22
 
22
23
  Every name starting with `.cot-` is the library's; item names never do.
@@ -58,13 +59,14 @@ The default root adds two levels above this:
58
59
  ## Liveness and retention (G4, G5)
59
60
 
60
61
  - A process that starts or joins a run writes a holder file with its pid,
61
- host and Linux boot id, and removes it when it closes the run. A run is
62
- live while any holder may be alive: same host and boot, and the pid
63
- exists. A holder from another host always counts as alive, so a run is
62
+ host, Linux boot id and process start time, and removes it when it closes
63
+ the run. A run is live while any holder may be alive: same host and boot,
64
+ the pid exists, and the process with that pid started when the holder
65
+ says. A holder from another host always counts as alive, so a run is
64
66
  never collected on a guess.
65
- - **Open:** a pid that is reused by an unrelated process keeps a crashed
66
- run alive until that process ends. Recording the process start time would
67
- close that gap.
67
+ - The start time comes from `/proc` on Linux and `GetProcessTimes` on
68
+ Windows. **Open:** macOS has neither, so there a pid reused by an
69
+ unrelated process keeps a crashed run alive until that process ends.
68
70
  - Closing a run applies retention to the whole root: the newest
69
71
  `keep_runs` runs stay, older ones that are not live go, and so do runs
70
72
  older than `max_age`. The run being closed counts as the newest. A
@@ -107,9 +107,9 @@ Time is a goal, not an afterthought, and it is measured.
107
107
  which today discards the folders of tests that error in setup or teardown.
108
108
  - Retention works with any root a caller gives the library, which
109
109
  `--basetemp` cannot do
110
- ([#10829](https://github.com/pytest-dev/pytest/issues/10829)). For pytest's
111
- `--basetemp` itself the rule is the opposite: it must name a new folder,
112
- and nothing under it is ever deleted.
110
+ ([#10829](https://github.com/pytest-dev/pytest/issues/10829)). pytest's
111
+ `--basetemp` becomes such a root: it holds the runs, and only folders
112
+ cot.tmppath made there are ever removed.
113
113
  - Retention is kept per project, so one project's runs never push out
114
114
  another's (in pytest today, every project shares one `pytest-of-{user}`
115
115
  counter).
@@ -72,12 +72,12 @@ is not worth relying on.
72
72
  `Path`), or deliberately leaves them out to push a suite off `py.path`.
73
73
  2. **`tmp_path_factory` duck type.** `mktemp(basename, numbered=True)` and
74
74
  `getbasetemp()`, as pytester and existing plugins and conftests call them.
75
- 3. **`--basetemp`.** It is still parsed and validated by pytest. The
76
- replacement reads `config.option.basetemp` and treats it as a new folder
77
- to create: an existing path is refused, because pytest's `--basetemp`
78
- deletes whatever is there. Nothing under a `--basetemp` is ever deleted,
79
- whatever the retention settings say; cleaning it up is the user's job.
80
- xdist hands workers the controller's options, so workers skip the check.
75
+ 3. **`--basetemp`.** It is still parsed and validated by pytest, which
76
+ deletes whatever is there. The replacement reads `config.option.basetemp`
77
+ and uses it as a cot.tmppath root that holds the runs: it is created if
78
+ missing, adopted if empty, reused if cot.tmppath made it, and refused
79
+ otherwise, so nothing it did not make is ever touched. xdist hands
80
+ workers the controller's options, so workers skip the check.
81
81
  4. **xdist workers join the controller's run.** With A, xdist's `hasattr`
82
82
  check fails silently and workers get no basetemp at all. The replacement
83
83
  implements `pytest_configure_node` (as an `optionalhook`) to put the run's
@@ -115,8 +115,11 @@ above:
115
115
  - In its `pytest_addoption` it unregisters the `tmpdir` plugin and blocks the
116
116
  name, so `-p no:tmpdir` is not needed. A plugin given with `-p` is loaded
117
117
  while the command line is pre-parsed, before any `pytest_configure`, so the
118
- tmpdir plugin never configures itself, `config._tmp_path_factory` never
119
- exists, and legacypath skips its `tmpdir` fixtures **(verified)**.
118
+ tmpdir plugin never configures itself and legacypath skips its `tmpdir`
119
+ fixtures **(verified)**. The plugin sets `config._tmp_path_factory` to its
120
+ own factory, so plugins that use it (pytest-xdist among them) get
121
+ cot.tmppath's folders, and pytest's basetemp handling is replaced as a
122
+ whole.
120
123
  - The tmpdir plugin's ini options were already registered by then, so
121
124
  `tmp_path_retention_count` and `tmp_path_retention_policy` stay valid under
122
125
  `--strict-config`. If the user also passes `-p no:tmpdir`, the plugin
@@ -125,29 +128,24 @@ above:
125
128
  `getbasetemp`. It deliberately leaves out the `py.path` fixtures `tmpdir`
126
129
  and `tmpdir_factory` (point 1), so a suite that still uses them fails with
127
130
  "fixture not found" and has to move to `tmp_path`.
128
- - `getbasetemp()` is the process's own folder in the run, named by the
129
- xdist controller (`gw0`, ...) or `main` without xdist, so
130
- `getbasetemp().parent` is the run's shared folder, as with pytest under
131
- xdist. Unlike pytest, `tmp_path` folders are not inside `getbasetemp()`:
132
- items sit flat in the run.
131
+ - `getbasetemp()` is the run folder. In an xdist worker it is the worker's
132
+ own folder in the run, named by the controller (`gw0`, ...), so
133
+ `getbasetemp().parent` is the run, as with pytest under xdist. As with
134
+ pytest, `tmp_path` and `mktemp` folders are made inside `getbasetemp()`
135
+ (`Run.item(name, process=...)`).
133
136
  - The run is started on first use, not at configure time, so a session that
134
137
  asks for no temporary folder creates nothing **(verified)**.
135
- - `--basetemp` must name a folder that does not exist yet; the plugin
136
- refuses an existing one with a usage error **(verified)**, creates the new
137
- one, and deletes nothing under it (`KEEP_EVERYTHING`).
138
+ - `--basetemp` names the root that holds the runs. A non-empty folder that
139
+ cot.tmppath did not make, a symlink, or another user's folder is refused
140
+ with a usage error and left untouched **(verified)**. Retention applies
141
+ inside it as anywhere else, to runs cot.tmppath made.
138
142
  - Without `--basetemp`, the root is `Root.for_project(rootdir name)`, and
139
143
  pytest's retention settings map onto `Retention`: `failed` keeps only
140
144
  failed items, `none` keeps no runs.
141
- - **An existing folder that is empty and less than 10 seconds old** is used
142
- with a `PytestWarning` instead of refused: it was made for this run by
143
- whoever started pytest, and holds nothing to lose. This is what
144
- `pytester.runpytest_subprocess` does: it always creates the `--basetemp`
145
- folder just before starting pytest **(verified)**.
146
- - **Intentional break:** `pytester.runpytest` passes the same `--basetemp`
147
- on every call in a test, so from the second call on the folder is neither
148
- empty nor fresh and the run is refused. Tests that rely on that, and any
149
- other caller that reuses a `--basetemp`, break on purpose; they must pass
150
- a new path.
145
+ - `pytester` works unchanged: `runpytest_subprocess` hands over a fresh empty
146
+ `--basetemp`, which is adopted, and inline `runpytest` passes the same
147
+ `--basetemp` on every call, which is reused because cot.tmppath made it
148
+ **(verified)**.
151
149
  - xdist workers get the root and run id through `pytest_configure_node` and
152
150
  join the controller's run.
153
151
  - An item's fate is decided at its `teardown` report from setup, call and
@@ -269,6 +269,7 @@ class Run:
269
269
  self._dir = folder
270
270
  self._token = f"{os.getpid()}-{secrets.token_hex(4)}"
271
271
  self._trash: Dir | None = None
272
+ self._processes: dict[str, Dir] = {}
272
273
  self._closed = False
273
274
 
274
275
  def _hold(self) -> None:
@@ -282,19 +283,27 @@ class Run:
282
283
  msg = f"run {self.path} is closed"
283
284
  raise ValueError(msg)
284
285
 
285
- def item(self, name: str) -> Path:
286
- """Create a new folder for ``name`` directly in the run folder."""
286
+ def item(self, name: str, *, process: str | None = None) -> Path:
287
+ """Create a new folder for ``name`` in the run folder, or inside
288
+ the process folder ``process`` when one is named."""
287
289
  self._check_open()
290
+ folder = self._dir if process is None else self._process_dir(process)
288
291
  layout = self._root.layout
289
292
  for _ in range(100_000):
290
- candidate = _check_name(layout.item_name(self.path, name), "item")
293
+ candidate = _check_name(layout.item_name(folder.path, name), "item")
291
294
  try:
292
- return self._dir.mkdir(candidate)
295
+ return folder.mkdir(candidate)
293
296
  except FileExistsError:
294
297
  continue
295
- msg = f"no free item name for {name!r} in {self.path}"
298
+ msg = f"no free item name for {name!r} in {folder.path}"
296
299
  raise FileExistsError(errno.EEXIST, msg)
297
300
 
301
+ def _process_dir(self, name: str) -> Dir:
302
+ if name not in self._processes:
303
+ self.process_folder(name)
304
+ self._processes[name] = self._dir.open_dir(name)
305
+ return self._processes[name]
306
+
298
307
  def process_folder(self, name: str) -> Path:
299
308
  """The folder of one process in this run, named by whoever manages
300
309
  the processes (for pytest-xdist, the controller names its workers).
@@ -321,7 +330,11 @@ class Run:
321
330
  """
322
331
  self._check_open()
323
332
  path = Path(path)
324
- if path.parent != self.path:
333
+ if path.parent == self.path:
334
+ folder = self._dir
335
+ elif path.parent.parent == self.path and path.parent.name in self._processes:
336
+ folder = self._processes[path.parent.name]
337
+ else:
325
338
  msg = f"{path} is not an item of run {self.path}"
326
339
  raise ValueError(msg)
327
340
  if outcome is Outcome.FAILED or not self._root.retention.keep_failed_only:
@@ -332,7 +345,7 @@ class Run:
332
345
  self._dir.mkdir(trash)
333
346
  self._trash = self._dir.open_dir(trash)
334
347
  with suppress(FileNotFoundError):
335
- self._dir.rename(path.name, secrets.token_hex(8), into=self._trash)
348
+ folder.rename(path.name, secrets.token_hex(8), into=self._trash)
336
349
 
337
350
  @contextmanager
338
351
  def place(self, destination: Path) -> Iterator[Path]:
@@ -362,6 +375,9 @@ class Run:
362
375
  holders.unlink(self._token)
363
376
  except FileNotFoundError:
364
377
  pass
378
+ for process in self._processes.values():
379
+ process.close()
380
+ self._processes.clear()
365
381
  if self._trash is not None:
366
382
  self._trash.close()
367
383
  reason = self._dir.rmtree(_TRASH + self._token)
@@ -551,6 +567,17 @@ class Root:
551
567
  folder = child
552
568
  return folder
553
569
 
570
+ def ensure(self) -> None:
571
+ """Create the root, or check an existing one, without starting a run.
572
+
573
+ Raises ``UnsafeRootError`` for anything the root may not use: a
574
+ symlink, another user's folder, or a non-empty folder cot.tmppath
575
+ did not create. Nothing in such a folder is touched.
576
+ """
577
+ folder = self._open(create=True)
578
+ assert folder is not None
579
+ folder.close()
580
+
554
581
  def start_run(self) -> Run:
555
582
  """Create a new run folder, held by this process."""
556
583
  root = self._open(create=True)
@@ -0,0 +1,141 @@
1
+ """Who holds a run, and whether they are still alive (G5)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+
12
+
13
+ def _boot_id() -> str:
14
+ # Linux names each boot; elsewhere a pid alone has to do
15
+ try:
16
+ return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
17
+ except OSError:
18
+ return ""
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class Owner:
23
+ """A process: its pid, host, boot and start time, so a pid reused by
24
+ another process, after a reboot or on another machine is not mistaken
25
+ for it. ``boot`` and ``started`` are empty where the platform cannot
26
+ tell (macOS); the pid alone decides there."""
27
+
28
+ pid: int
29
+ host: str
30
+ boot: str
31
+ started: str = ""
32
+
33
+ @classmethod
34
+ def current(cls) -> Owner:
35
+ pid = os.getpid()
36
+ return cls(pid, socket.gethostname(), _BOOT, _process_start(pid))
37
+
38
+ def to_bytes(self) -> bytes:
39
+ return json.dumps(
40
+ {
41
+ "pid": self.pid,
42
+ "host": self.host,
43
+ "boot": self.boot,
44
+ "started": self.started,
45
+ }
46
+ ).encode()
47
+
48
+ @classmethod
49
+ def from_bytes(cls, data: bytes) -> Owner | None:
50
+ try:
51
+ raw = json.loads(data)
52
+ return cls(
53
+ int(raw["pid"]),
54
+ str(raw["host"]),
55
+ str(raw["boot"]),
56
+ # holder files written by 0.2.0 have no start time
57
+ str(raw.get("started", "")),
58
+ )
59
+ except (ValueError, KeyError, TypeError, AttributeError):
60
+ return None
61
+
62
+ def is_alive(self) -> bool:
63
+ """False only when this process is known to be gone.
64
+
65
+ An owner on another host cannot be checked from here, so it counts
66
+ as alive: a run is never collected on a guess.
67
+ """
68
+ here = Owner.current()
69
+ if self.host != here.host:
70
+ return True
71
+ if self.boot and here.boot and self.boot != here.boot:
72
+ return False
73
+ if not _pid_alive(self.pid):
74
+ return False
75
+ if self.started:
76
+ # a process with this pid exists; is it the one that wrote this?
77
+ now = _process_start(self.pid)
78
+ if now and now != self.started:
79
+ return False
80
+ return True
81
+
82
+
83
+ _BOOT = _boot_id()
84
+
85
+ if sys.platform == "win32":
86
+ import ctypes
87
+
88
+ _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
89
+ _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
90
+ _STILL_ACTIVE = 259
91
+ _ERROR_ACCESS_DENIED = 5
92
+
93
+ class _FileTime(ctypes.Structure):
94
+ _fields_ = (("low", ctypes.c_ulong), ("high", ctypes.c_ulong))
95
+
96
+ def _process_start(pid: int) -> str:
97
+ handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
98
+ if not handle:
99
+ return ""
100
+ try:
101
+ times = [_FileTime() for _ in range(4)]
102
+ if not _kernel32.GetProcessTimes(
103
+ handle, *(ctypes.byref(time) for time in times)
104
+ ):
105
+ return ""
106
+ return str(times[0].high << 32 | times[0].low)
107
+ finally:
108
+ _kernel32.CloseHandle(handle)
109
+
110
+ def _pid_alive(pid: int) -> bool:
111
+ handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
112
+ if not handle:
113
+ return bool(ctypes.get_last_error() == _ERROR_ACCESS_DENIED)
114
+ try:
115
+ code = ctypes.c_ulong()
116
+ if not _kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
117
+ return True
118
+ return code.value == _STILL_ACTIVE
119
+ finally:
120
+ _kernel32.CloseHandle(handle)
121
+
122
+ else:
123
+
124
+ def _process_start(pid: int) -> str:
125
+ # Linux: field 22 of /proc/{pid}/stat, in clock ticks since boot;
126
+ # the name before it can hold spaces and parentheses
127
+ try:
128
+ stat = Path(f"/proc/{pid}/stat").read_text()
129
+ except OSError:
130
+ return ""
131
+ fields = stat.rpartition(")")[2].split()
132
+ return fields[19] if len(fields) > 19 else ""
133
+
134
+ def _pid_alive(pid: int) -> bool:
135
+ try:
136
+ os.kill(pid, 0)
137
+ except ProcessLookupError:
138
+ return False
139
+ except PermissionError:
140
+ return True
141
+ return True
@@ -9,10 +9,15 @@ Loaded with ``-p``, it unregisters pytest's ``tmpdir`` plugin and provides
9
9
  ``tmp_path`` and ``tmp_path_factory`` itself. The ``py.path`` fixtures
10
10
  ``tmpdir`` and ``tmpdir_factory`` are left out on purpose: with the plugin
11
11
  on, a test that asks for them fails with "fixture not found".
12
- pytest's ``tmp_path_retention_count`` and ``tmp_path_retention_policy``
13
- settings keep working, and pytest-xdist workers join the controller's run.
14
- ``--basetemp`` must name a folder that does not exist yet; it is created and
15
- nothing under it is ever deleted. See docs/pytest-replacement.md.
12
+ It also sets ``config._tmp_path_factory``, so pytest's basetemp handling is
13
+ replaced as a whole. pytest's ``tmp_path_retention_count`` and
14
+ ``tmp_path_retention_policy`` settings keep working.
15
+
16
+ Layout: ``getbasetemp()`` is the run folder, and ``tmp_path`` folders are
17
+ made inside it. An xdist worker's ``getbasetemp()`` is its own folder in the
18
+ controller's run. ``--basetemp`` names the root that holds the runs; it is
19
+ used only if it is missing, empty, or made by cot.tmppath, and only folders
20
+ cot.tmppath made there are ever removed. See docs/pytest-replacement.md.
16
21
 
17
22
  This module imports pytest; the rest of cot.tmppath never does.
18
23
  """
@@ -21,8 +26,6 @@ from __future__ import annotations
21
26
 
22
27
  import os
23
28
  import re
24
- import stat
25
- import time
26
29
  import warnings
27
30
  from collections.abc import Generator
28
31
  from pathlib import Path
@@ -30,7 +33,7 @@ from typing import Any
30
33
 
31
34
  import pytest
32
35
 
33
- from ._api import KEEP_EVERYTHING, Outcome, Retention, Root, Run
36
+ from ._api import Outcome, Retention, Root, Run, UnsafeRootError
34
37
 
35
38
  _RUN_ID = "cot_tmppath_run"
36
39
  _ROOT = "cot_tmppath_root"
@@ -81,17 +84,25 @@ class _State:
81
84
 
82
85
  def __init__(self, config: pytest.Config) -> None:
83
86
  self._config = config
87
+ self._root: Root | None = None
84
88
  self._run: Run | None = None
85
89
 
86
- def _root(self) -> Root:
90
+ @property
91
+ def root(self) -> Root:
92
+ if self._root is None:
93
+ self._root = self._make_root()
94
+ return self._root
95
+
96
+ def _make_root(self) -> Root:
87
97
  retention = _retention(self._config)
88
98
  worker = getattr(self._config, "workerinput", None)
89
99
  if worker is not None:
90
100
  return Root(Path(worker[_ROOT]), retention=retention)
91
101
  basetemp = self._config.option.basetemp
92
102
  if basetemp:
93
- # a folder the user named is never pruned, whatever retention says
94
- return Root(Path(basetemp), retention=KEEP_EVERYTHING)
103
+ # a root like any other: runs go inside it, and only folders
104
+ # cot.tmppath made there are ever removed
105
+ return Root(Path(basetemp), retention=retention)
95
106
  temproot = os.environ.get("PYTEST_DEBUG_TEMPROOT")
96
107
  return Root.for_project(
97
108
  self._config.rootpath.name,
@@ -100,14 +111,16 @@ class _State:
100
111
  )
101
112
 
102
113
  @property
103
- def process(self) -> str:
114
+ def process(self) -> str | None:
115
+ """The xdist worker's folder name; None for the controller and
116
+ for a run without xdist, whose folders go in the run itself."""
104
117
  worker = getattr(self._config, "workerinput", None)
105
- return str(worker[_PROCESS]) if worker is not None else "main"
118
+ return str(worker[_PROCESS]) if worker is not None else None
106
119
 
107
120
  @property
108
121
  def run(self) -> Run:
109
122
  if self._run is None:
110
- root = self._root()
123
+ root = self.root
111
124
  worker = getattr(self._config, "workerinput", None)
112
125
  if worker is not None:
113
126
  self._run = root.join_run(worker[_RUN_ID])
@@ -126,50 +139,23 @@ class _State:
126
139
  )
127
140
 
128
141
 
129
- # An empty folder this young was made by whoever started pytest, for this
130
- # run, the way pytester's runpytest_subprocess does it.
131
- _FRESH_SECONDS = 10.0
132
-
133
-
134
142
  def _check_basetemp(config: pytest.Config) -> None:
135
- basetemp = config.option.basetemp
136
- # xdist hands workers the controller's options, by then the folder exists
137
- if not basetemp or hasattr(config, "workerinput"):
143
+ # xdist hands workers the controller's options; the controller checked
144
+ if not config.option.basetemp or hasattr(config, "workerinput"):
138
145
  return
139
- path = Path(basetemp)
140
146
  try:
141
- info = path.lstat()
142
- except FileNotFoundError:
143
- return
144
- # lstat, never stat: a symlink to a fresh empty folder is not fresh
145
- owned = not hasattr(os, "getuid") or info.st_uid == os.getuid()
146
- fresh = (
147
- stat.S_ISDIR(info.st_mode)
148
- and owned
149
- and time.time() - info.st_mtime < _FRESH_SECONDS
150
- and not any(path.iterdir())
151
- )
152
- if fresh:
153
- config.issue_config_time_warning(
154
- pytest.PytestWarning(
155
- f"--basetemp={basetemp} already exists; using it because it is"
156
- f" empty and less than {_FRESH_SECONDS:.0f}s old. Pass a path"
157
- " that does not exist yet."
158
- ),
159
- stacklevel=2,
160
- )
161
- return
162
- msg = (
163
- f"--basetemp={basetemp} already exists. cot.tmppath only creates a"
164
- " new folder there and never deletes one; remove it yourself or"
165
- " name a path that does not exist yet."
166
- )
167
- raise pytest.UsageError(msg)
147
+ config.stash[_state].root.ensure()
148
+ except UnsafeRootError as error:
149
+ msg = f"--basetemp={config.option.basetemp}: {error.strerror}"
150
+ raise pytest.UsageError(msg) from None
168
151
 
169
152
 
170
153
  def pytest_configure(config: pytest.Config) -> None:
154
+ state = config.stash[_state] = _State(config)
171
155
  _check_basetemp(config)
172
- config.stash[_state] = _State(config)
156
+ # what pytest's own tmpdir plugin sets: pytest-xdist and other plugins
157
+ # look for it, and get the cot.tmppath factory
158
+ config._tmp_path_factory = TempPathFactory(state) # type: ignore[attr-defined]
173
159
 
174
160
 
175
161
  @pytest.hookimpl(optionalhook=True)
@@ -192,19 +178,21 @@ class TempPathFactory:
192
178
  self._state = state
193
179
 
194
180
  def getbasetemp(self) -> Path:
195
- # per process, so getbasetemp().parent is the run's shared folder,
196
- # as it is for pytest under xdist
197
- return self._state.run.process_folder(self._state.process)
181
+ # the run; in an xdist worker its own folder in the run, so
182
+ # getbasetemp().parent is the run there, as it is for pytest
183
+ run, process = self._state.run, self._state.process
184
+ return run.path if process is None else run.process_folder(process)
198
185
 
199
186
  def mktemp(self, basename: str, numbered: bool = True) -> Path:
200
- # Every item gets a unique name; numbered=False cannot promise the
201
- # exact name in a run that other processes share.
202
- return self._state.run.item(basename)
187
+ # Inside getbasetemp(), as pytest does. Every item gets a unique
188
+ # name; numbered=False cannot promise the exact name.
189
+ return self._state.run.item(basename, process=self._state.process)
203
190
 
204
191
 
205
192
  @pytest.fixture(scope="session")
206
193
  def tmp_path_factory(request: pytest.FixtureRequest) -> TempPathFactory:
207
- return TempPathFactory(request.config.stash[_state])
194
+ factory: TempPathFactory = request.config._tmp_path_factory # type: ignore[attr-defined]
195
+ return factory
208
196
 
209
197
 
210
198
  @pytest.fixture
@@ -11,6 +11,7 @@ import pytest
11
11
 
12
12
  from conftest import run_python
13
13
  from cot.tmppath import Retention, Root
14
+ from cot.tmppath._owner import Owner
14
15
 
15
16
 
16
17
  def test_another_process_can_join_a_run(root_path: Path) -> None:
@@ -87,3 +88,17 @@ def test_place_is_atomic(root_path: Path) -> None:
87
88
  staging.write_bytes(b"whole")
88
89
  assert destination.read_bytes() == b"whole"
89
90
  assert list(destination.parent.iterdir()) == [destination]
91
+
92
+
93
+ @pytest.mark.skipif(
94
+ not Owner.current().started, reason="no process start time on this platform"
95
+ )
96
+ def test_reused_pid_does_not_keep_a_run_alive(root_path: Path) -> None:
97
+ run = Root(root_path).start_run()
98
+ # what a crashed holder looks like once its pid went to another process
99
+ here = Owner.current()
100
+ reused = Owner(here.pid, here.host, here.boot, started="0")
101
+ (holder,) = (run.path / ".cot-holders").iterdir()
102
+ holder.write_bytes(reused.to_bytes())
103
+ Root(root_path, retention=Retention(keep_runs=0)).prune()
104
+ assert not run.path.exists()
@@ -7,9 +7,7 @@ behaviour tests are xfail until the core is built.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
- import os
11
10
  import sys
12
- import time
13
11
 
14
12
  import pytest
15
13
 
@@ -31,7 +29,7 @@ def test_addopts_opt_in_takes_over(pytester: pytest.Pytester) -> None:
31
29
  def test_takeover(request, tmp_path_factory):
32
30
  config = request.config
33
31
  assert not config.pluginmanager.has_plugin("tmpdir")
34
- assert not hasattr(config, "_tmp_path_factory")
32
+ assert config._tmp_path_factory is tmp_path_factory
35
33
  assert type(tmp_path_factory).__module__ == {PLUGIN!r}
36
34
  """
37
35
  )
@@ -147,18 +145,27 @@ def test_existing_basetemp_is_refused(pytester: pytest.Pytester) -> None:
147
145
  pytester.makepyfile("def test_nothing(): pass")
148
146
  result = _run(pytester, "-p", PLUGIN, f"--basetemp={existing}")
149
147
  assert result.ret == pytest.ExitCode.USAGE_ERROR
150
- result.stderr.fnmatch_lines(["*--basetemp=*existing already exists*"])
148
+ result.stderr.fnmatch_lines(["*--basetemp=*not created by cot.tmppath*"])
151
149
  assert precious.read_text() == "keep me"
150
+ assert sorted(p.name for p in existing.iterdir()) == ["precious.txt"]
152
151
 
153
152
 
154
- def test_new_basetemp_is_created_and_never_pruned(pytester: pytest.Pytester) -> None:
153
+ def test_basetemp_holds_the_runs_and_is_reused(pytester: pytest.Pytester) -> None:
155
154
  base = pytester.path / "new"
156
- pytester.makeini(
157
- "[pytest]\ntmp_path_retention_policy = none\ntmp_path_retention_count = 0\n"
155
+ pytester.makeini("[pytest]\ntmp_path_retention_count = 2\n")
156
+ pytester.makepyfile(
157
+ """
158
+ def test_layout(tmp_path, tmp_path_factory):
159
+ run = tmp_path_factory.getbasetemp()
160
+ assert tmp_path.parent == run
161
+ assert run.parent.name == "new"
162
+ (tmp_path / "f").touch()
163
+ """
158
164
  )
159
- pytester.makepyfile("def test_passes(tmp_path): (tmp_path / 'f').touch()")
160
- _run(pytester, "-p", PLUGIN, f"--basetemp={base}").assert_outcomes(passed=1)
161
- assert list(base.rglob("f"))
165
+ for _ in range(3):
166
+ _run(pytester, "-p", PLUGIN, f"--basetemp={base}").assert_outcomes(passed=1)
167
+ # retention applies inside a --basetemp too, to runs cot.tmppath made
168
+ assert len([p for p in base.iterdir() if p.is_dir()]) == 2
162
169
 
163
170
 
164
171
  def test_xdist_workers_accept_the_new_basetemp(pytester: pytest.Pytester) -> None:
@@ -169,29 +176,24 @@ def test_xdist_workers_accept_the_new_basetemp(pytester: pytest.Pytester) -> Non
169
176
  result.assert_outcomes(passed=2)
170
177
 
171
178
 
172
- def test_fresh_empty_basetemp_is_used_with_a_warning(pytester: pytest.Pytester) -> None:
173
- fresh = pytester.mkdir("fresh")
174
- pytester.makepyfile("def test_nothing(): pass")
175
- result = _run(pytester, "-p", PLUGIN, f"--basetemp={fresh}")
176
- result.assert_outcomes(passed=1, warnings=1)
177
- result.stdout.fnmatch_lines(["*--basetemp=*fresh already exists; using it*"])
179
+ def test_empty_basetemp_is_adopted(pytester: pytest.Pytester) -> None:
180
+ empty = pytester.mkdir("empty")
181
+ pytester.makepyfile("def test_one(tmp_path): pass")
182
+ _run(pytester, "-p", PLUGIN, f"--basetemp={empty}").assert_outcomes(passed=1)
183
+ assert (empty / ".cot-tmppath").is_file()
178
184
 
179
185
 
180
- def test_old_empty_basetemp_is_refused(pytester: pytest.Pytester) -> None:
181
- old = pytester.mkdir("old")
182
- an_hour_ago = time.time() - 3600
183
- os.utime(old, (an_hour_ago, an_hour_ago))
184
- pytester.makepyfile("def test_nothing(): pass")
185
- result = _run(pytester, "-p", PLUGIN, f"--basetemp={old}")
186
- assert result.ret == pytest.ExitCode.USAGE_ERROR
187
-
188
-
189
- def test_pytesters_own_subprocess_runs_work_with_a_warning(
190
- pytester: pytest.Pytester,
191
- ) -> None:
192
- pytester.makepyfile("def test_nothing(): pass")
193
- result = pytester.runpytest_subprocess("-p", "no:cacheprovider", "-p", PLUGIN)
194
- result.assert_outcomes(passed=1, warnings=1)
186
+ def test_pytesters_runs_work(pytester: pytest.Pytester) -> None:
187
+ # runpytest_subprocess hands over a fresh empty --basetemp; inline runs
188
+ # reuse the same --basetemp for every call in a test
189
+ pytester.makepyfile("def test_one(tmp_path): pass")
190
+ for _ in range(2):
191
+ result = pytester.runpytest_subprocess("-p", "no:cacheprovider", "-p", PLUGIN)
192
+ result.assert_outcomes(passed=1)
193
+ pytester.makeconftest("")
194
+ for _ in range(2):
195
+ result = pytester.runpytest_inprocess("-p", "no:cacheprovider", "-p", PLUGIN)
196
+ result.assert_outcomes(passed=1)
195
197
 
196
198
 
197
199
  def test_symlink_to_a_fresh_empty_folder_is_refused(pytester: pytest.Pytester) -> None:
@@ -227,3 +229,24 @@ def test_getbasetemp_is_per_process_and_its_parent_is_the_run(
227
229
  result.assert_outcomes(passed=4)
228
230
  (run,) = [p for p in base.iterdir() if p.is_dir()]
229
231
  assert len(list(run.glob("shared-*"))) == 4
232
+
233
+
234
+ @pytest.mark.parametrize("workers", [None, "2"])
235
+ def test_folders_are_made_inside_getbasetemp(
236
+ pytester: pytest.Pytester, workers: str | None
237
+ ) -> None:
238
+ if workers:
239
+ pytest.importorskip("xdist")
240
+ pytester.makepyfile(
241
+ """
242
+ def test_tmp_path(tmp_path, tmp_path_factory):
243
+ assert tmp_path.parent == tmp_path_factory.getbasetemp()
244
+
245
+ def test_mktemp(tmp_path_factory):
246
+ made = tmp_path_factory.mktemp("made")
247
+ assert made.parent == tmp_path_factory.getbasetemp()
248
+ """
249
+ )
250
+ base = pytester.path / "new"
251
+ args = ["-n", workers] if workers else []
252
+ _run(pytester, "-p", PLUGIN, f"--basetemp={base}", *args).assert_outcomes(passed=2)
@@ -17,6 +17,18 @@ def test_keep_failed_only_keeps_failed_items(root_path: Path) -> None:
17
17
  assert failed.is_dir()
18
18
 
19
19
 
20
+ def test_items_in_a_process_folder_follow_retention(root_path: Path) -> None:
21
+ root = Root(root_path, retention=Retention(keep_failed_only=True))
22
+ with root.start_run() as run:
23
+ passed = run.item("test_ok", process="gw0")
24
+ failed = run.item("test_bad", process="gw0")
25
+ assert passed.parent == failed.parent == run.process_folder("gw0")
26
+ run.finish_item(passed, Outcome.PASSED)
27
+ run.finish_item(failed, Outcome.FAILED)
28
+ assert not passed.exists()
29
+ assert failed.is_dir()
30
+
31
+
20
32
  def test_custom_root_keeps_the_last_runs(root_path: Path) -> None:
21
33
  root = Root(root_path, retention=Retention(keep_runs=3))
22
34
  runs = []
@@ -1,13 +0,0 @@
1
- # Changelog
2
-
3
- <!-- towncrier release notes start -->
4
-
5
- ## 0.2.0 (2026-10-07)
6
-
7
- ### Removed
8
-
9
- - The pytest plugin no longer provides the `py.path` fixtures `tmpdir` and `tmpdir_factory`; use `tmp_path` and `tmp_path_factory`.
10
-
11
- ### Added
12
-
13
- - Build the core: `Root`, `Run`, the flat and pytest layouts, retention, pruning, liveness and atomic placement now work instead of raising `NotImplementedError`, so `-p cot.tmppath.overtake_pytest` provides working `tmp_path` and `tmp_path_factory` fixtures.
@@ -1,92 +0,0 @@
1
- """Who holds a run, and whether they are still alive (G5)."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import os
7
- import socket
8
- import sys
9
- from dataclasses import dataclass
10
- from pathlib import Path
11
-
12
-
13
- def _boot_id() -> str:
14
- # Linux names each boot; elsewhere a pid alone has to do
15
- try:
16
- return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
17
- except OSError:
18
- return ""
19
-
20
-
21
- @dataclass(frozen=True)
22
- class Owner:
23
- """A process: its pid, host and boot, so a reused pid after a reboot
24
- or on another machine is not mistaken for it."""
25
-
26
- pid: int
27
- host: str
28
- boot: str
29
-
30
- @classmethod
31
- def current(cls) -> Owner:
32
- return cls(os.getpid(), socket.gethostname(), _BOOT)
33
-
34
- def to_bytes(self) -> bytes:
35
- return json.dumps(
36
- {"pid": self.pid, "host": self.host, "boot": self.boot}
37
- ).encode()
38
-
39
- @classmethod
40
- def from_bytes(cls, data: bytes) -> Owner | None:
41
- try:
42
- raw = json.loads(data)
43
- return cls(int(raw["pid"]), str(raw["host"]), str(raw["boot"]))
44
- except (ValueError, KeyError, TypeError):
45
- return None
46
-
47
- def is_alive(self) -> bool:
48
- """False only when this process is known to be gone.
49
-
50
- An owner on another host cannot be checked from here, so it counts
51
- as alive: a run is never collected on a guess.
52
- """
53
- here = Owner.current()
54
- if self.host != here.host:
55
- return True
56
- if self.boot and here.boot and self.boot != here.boot:
57
- return False
58
- return _pid_alive(self.pid)
59
-
60
-
61
- _BOOT = _boot_id()
62
-
63
- if sys.platform == "win32":
64
- import ctypes
65
-
66
- _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
67
- _PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
68
- _STILL_ACTIVE = 259
69
- _ERROR_ACCESS_DENIED = 5
70
-
71
- def _pid_alive(pid: int) -> bool:
72
- handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
73
- if not handle:
74
- return bool(ctypes.get_last_error() == _ERROR_ACCESS_DENIED)
75
- try:
76
- code = ctypes.c_ulong()
77
- if not _kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
78
- return True
79
- return code.value == _STILL_ACTIVE
80
- finally:
81
- _kernel32.CloseHandle(handle)
82
-
83
- else:
84
-
85
- def _pid_alive(pid: int) -> bool:
86
- try:
87
- os.kill(pid, 0)
88
- except ProcessLookupError:
89
- return False
90
- except PermissionError:
91
- return True
92
- return True
File without changes
File without changes
File without changes
File without changes
File without changes