cot.tmppath 0.2.1__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 (39) hide show
  1. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/CHANGELOG.md +6 -0
  2. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/PKG-INFO +1 -1
  3. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/docs/core.md +2 -1
  4. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/docs/goals.md +3 -3
  5. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/docs/pytest-replacement.md +24 -26
  6. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/src/cot/tmppath/_api.py +34 -7
  7. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/src/cot/tmppath/overtake_pytest.py +45 -57
  8. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_overtake_pytest.py +54 -31
  9. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_retention.py +12 -0
  10. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/.github/dependabot.yml +0 -0
  11. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/.github/workflows/ci.yml +0 -0
  12. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/.github/workflows/release-proposal.yml +0 -0
  13. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/.github/workflows/release-tag.yml +0 -0
  14. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/.github/workflows/release.yml +0 -0
  15. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/.gitignore +0 -0
  16. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/.pre-commit-config.yaml +0 -0
  17. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/LICENSE +0 -0
  18. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/README.md +0 -0
  19. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/benchmarks/test_bench.py +0 -0
  20. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/changelog.d/README.md +0 -0
  21. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/changelog.d/template.md +0 -0
  22. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/docs/assessment.md +0 -0
  23. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/docs/releasing.md +0 -0
  24. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/docs/research.md +0 -0
  25. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/pyproject.toml +0 -0
  26. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/src/cot/tmppath/__init__.py +0 -0
  27. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/src/cot/tmppath/__main__.py +0 -0
  28. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/src/cot/tmppath/_fs.py +0 -0
  29. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/src/cot/tmppath/_owner.py +0 -0
  30. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/src/cot/tmppath/py.typed +0 -0
  31. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/conftest.py +0 -0
  32. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_cli.py +0 -0
  33. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_concurrency.py +0 -0
  34. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_core.py +0 -0
  35. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_hardening.py +0 -0
  36. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_layout.py +0 -0
  37. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_pytest_replacement.py +0 -0
  38. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/testing/test_speed.py +0 -0
  39. {cot_tmppath-0.2.1 → cot_tmppath-0.3.0}/uv.lock +0 -0
@@ -2,6 +2,12 @@
2
2
 
3
3
  <!-- towncrier release notes start -->
4
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
+
5
11
  ## 0.2.1 (2026-10-07)
6
12
 
7
13
  ### Fixed
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: cot.tmppath
3
- Version: 0.2.1
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.
@@ -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)
@@ -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
@@ -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 = []
File without changes
File without changes
File without changes
File without changes
File without changes