cot.tmppath 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.
- cot/tmppath/__init__.py +31 -0
- cot/tmppath/__main__.py +118 -0
- cot/tmppath/_api.py +205 -0
- cot/tmppath/overtake_pytest.py +251 -0
- cot/tmppath/py.typed +0 -0
- cot_tmppath-0.1.0.dist-info/METADATA +54 -0
- cot_tmppath-0.1.0.dist-info/RECORD +10 -0
- cot_tmppath-0.1.0.dist-info/WHEEL +4 -0
- cot_tmppath-0.1.0.dist-info/entry_points.txt +2 -0
- cot_tmppath-0.1.0.dist-info/licenses/LICENSE +21 -0
cot/tmppath/__init__.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Hardened, fast management of related temporary folders."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._api import (
|
|
6
|
+
KEEP_EVERYTHING,
|
|
7
|
+
FlatLayout,
|
|
8
|
+
Layout,
|
|
9
|
+
Outcome,
|
|
10
|
+
PrunePlan,
|
|
11
|
+
PruneReport,
|
|
12
|
+
PytestLayout,
|
|
13
|
+
Retention,
|
|
14
|
+
Root,
|
|
15
|
+
Run,
|
|
16
|
+
UnsafeRootError,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"KEEP_EVERYTHING",
|
|
21
|
+
"FlatLayout",
|
|
22
|
+
"Layout",
|
|
23
|
+
"Outcome",
|
|
24
|
+
"PrunePlan",
|
|
25
|
+
"PruneReport",
|
|
26
|
+
"PytestLayout",
|
|
27
|
+
"Retention",
|
|
28
|
+
"Root",
|
|
29
|
+
"Run",
|
|
30
|
+
"UnsafeRootError",
|
|
31
|
+
]
|
cot/tmppath/__main__.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""``python -m cot.tmppath prune``: remove old runs, asking first.
|
|
2
|
+
|
|
3
|
+
This is the application side, so unlike the library it prints and exits.
|
|
4
|
+
It removes only what the library would: folders cot.tmppath created, owned
|
|
5
|
+
by the current user, whose runs are not live.
|
|
6
|
+
|
|
7
|
+
By default it lists what it would remove and asks for confirmation.
|
|
8
|
+
``--dry-run`` only lists. Removing without asking needs a flag that is long
|
|
9
|
+
on purpose, so nobody types it by accident or copies it without reading.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import re
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import TextIO
|
|
20
|
+
|
|
21
|
+
from ._api import Root
|
|
22
|
+
|
|
23
|
+
NO_CONFIRMATION_FLAG = "--delete-without-asking-i-have-read-the-dry-run"
|
|
24
|
+
|
|
25
|
+
_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_duration(text: str) -> float:
|
|
29
|
+
"""``30s``, ``15m``, ``12h``, ``7d`` or ``2w`` in seconds."""
|
|
30
|
+
match = re.fullmatch(r"(\d+)([smhdw])", text.strip())
|
|
31
|
+
if match is None:
|
|
32
|
+
msg = f"not a duration: {text!r} (use e.g. 30m, 12h, 7d)"
|
|
33
|
+
raise argparse.ArgumentTypeError(msg)
|
|
34
|
+
return int(match[1]) * _UNITS[match[2]]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _parser() -> argparse.ArgumentParser:
|
|
38
|
+
# no abbreviations: "--delete" must not expand to the long flag
|
|
39
|
+
parser = argparse.ArgumentParser(prog="python -m cot.tmppath", allow_abbrev=False)
|
|
40
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
41
|
+
prune = commands.add_parser(
|
|
42
|
+
"prune", help="remove runs that retention no longer keeps", allow_abbrev=False
|
|
43
|
+
)
|
|
44
|
+
prune.add_argument("roots", nargs="*", type=Path, metavar="ROOT")
|
|
45
|
+
prune.add_argument(
|
|
46
|
+
"--all-projects",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="every project of the current user under the default location",
|
|
49
|
+
)
|
|
50
|
+
prune.add_argument(
|
|
51
|
+
"--older-than",
|
|
52
|
+
type=parse_duration,
|
|
53
|
+
metavar="DURATION",
|
|
54
|
+
help="also remove runs unused for this long (30m, 12h, 7d, 2w)",
|
|
55
|
+
)
|
|
56
|
+
mode = prune.add_mutually_exclusive_group()
|
|
57
|
+
mode.add_argument(
|
|
58
|
+
"--dry-run", action="store_true", help="only list what would be removed"
|
|
59
|
+
)
|
|
60
|
+
mode.add_argument(
|
|
61
|
+
NO_CONFIRMATION_FLAG,
|
|
62
|
+
dest="no_confirmation",
|
|
63
|
+
action="store_true",
|
|
64
|
+
help="remove without asking; for scripts, after checking --dry-run",
|
|
65
|
+
)
|
|
66
|
+
return parser
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(
|
|
70
|
+
argv: Sequence[str] | None = None,
|
|
71
|
+
*,
|
|
72
|
+
stdin: TextIO | None = None,
|
|
73
|
+
stdout: TextIO | None = None,
|
|
74
|
+
) -> int:
|
|
75
|
+
stdin = sys.stdin if stdin is None else stdin
|
|
76
|
+
stdout = sys.stdout if stdout is None else stdout
|
|
77
|
+
parser = _parser()
|
|
78
|
+
args = parser.parse_args(argv)
|
|
79
|
+
if not args.roots and not args.all_projects:
|
|
80
|
+
parser.error("name at least one ROOT, or pass --all-projects")
|
|
81
|
+
asking = not (args.dry_run or args.no_confirmation)
|
|
82
|
+
if asking and not stdin.isatty():
|
|
83
|
+
parser.error(
|
|
84
|
+
"cannot ask for confirmation, stdin is not a terminal;"
|
|
85
|
+
f" use --dry-run, or {NO_CONFIRMATION_FLAG}"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
roots = [Root(path) for path in args.roots]
|
|
89
|
+
if args.all_projects:
|
|
90
|
+
roots.extend(Root.all_projects())
|
|
91
|
+
plans = [root.plan_prune(older_than=args.older_than) for root in roots]
|
|
92
|
+
paths = [path for plan in plans for path in plan.paths]
|
|
93
|
+
if not paths:
|
|
94
|
+
print("nothing to remove", file=stdout)
|
|
95
|
+
return 0
|
|
96
|
+
for path in paths:
|
|
97
|
+
print(path, file=stdout)
|
|
98
|
+
if args.dry_run:
|
|
99
|
+
print(f"{len(paths)} folders would be removed (dry run)", file=stdout)
|
|
100
|
+
return 0
|
|
101
|
+
if asking:
|
|
102
|
+
print(f"remove these {len(paths)} folders? type 'yes': ", end="", file=stdout)
|
|
103
|
+
stdout.flush()
|
|
104
|
+
if stdin.readline().strip() != "yes":
|
|
105
|
+
print("nothing removed", file=stdout)
|
|
106
|
+
return 1
|
|
107
|
+
|
|
108
|
+
failed = 0
|
|
109
|
+
for plan in plans:
|
|
110
|
+
report = plan.apply()
|
|
111
|
+
for path, reason in report.failed:
|
|
112
|
+
failed += 1
|
|
113
|
+
print(f"could not remove {path}: {reason}", file=stdout)
|
|
114
|
+
return 1 if failed else 0
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
if __name__ == "__main__":
|
|
118
|
+
sys.exit(main())
|
cot/tmppath/_api.py
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""The intended public API, as stubs.
|
|
2
|
+
|
|
3
|
+
Nothing here is built yet: every entry point raises ``NotImplementedError``.
|
|
4
|
+
The shapes exist so that ``testing/`` can state what the library is for
|
|
5
|
+
(``docs/goals.md``) against real names, and so the tests flip from xfail to
|
|
6
|
+
pass one by one as the core lands.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import enum
|
|
12
|
+
from contextlib import AbstractContextManager
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Protocol
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
# typing_extensions is only needed by the type checker: no runtime deps
|
|
19
|
+
from typing_extensions import Self
|
|
20
|
+
|
|
21
|
+
_NOT_BUILT = "cot.tmppath: not built yet, see docs/goals.md"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class UnsafeRootError(OSError):
|
|
25
|
+
"""A root, or a folder at a shared level, is not safe to use.
|
|
26
|
+
|
|
27
|
+
Raised for a symlink, a folder owned by another user, permissions
|
|
28
|
+
others can use, or an existing folder this library did not create.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class Outcome(enum.Enum):
|
|
33
|
+
"""What a host reports about an item when it is done with it."""
|
|
34
|
+
|
|
35
|
+
PASSED = "passed"
|
|
36
|
+
FAILED = "failed"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Retention:
|
|
41
|
+
"""Which runs and items survive.
|
|
42
|
+
|
|
43
|
+
``keep_failed_only`` removes an item's folder when its whole outcome
|
|
44
|
+
passed. ``keep_runs`` and ``max_age`` bound the runs kept per root.
|
|
45
|
+
``KEEP_EVERYTHING`` deletes nothing at all.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
keep_failed_only: bool = False
|
|
49
|
+
keep_runs: int | None = 3 # None: no limit
|
|
50
|
+
max_age: float | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_DEFAULT_RETENTION = Retention()
|
|
54
|
+
KEEP_EVERYTHING = Retention(keep_failed_only=False, keep_runs=None)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class PruneReport:
|
|
59
|
+
"""What a prune removed, and what it could not remove, with why."""
|
|
60
|
+
|
|
61
|
+
removed: tuple[Path, ...] = ()
|
|
62
|
+
failed: tuple[tuple[Path, str], ...] = field(default=())
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@dataclass(frozen=True)
|
|
66
|
+
class PrunePlan:
|
|
67
|
+
"""Runs a prune selected, to show before anything is removed."""
|
|
68
|
+
|
|
69
|
+
paths: tuple[Path, ...] = ()
|
|
70
|
+
|
|
71
|
+
def apply(self) -> PruneReport:
|
|
72
|
+
"""Remove exactly the planned paths that are still safe to remove.
|
|
73
|
+
|
|
74
|
+
Each one is checked again (marker, owner, owner dead), so a run that
|
|
75
|
+
came alive since the plan was made is skipped and reported.
|
|
76
|
+
"""
|
|
77
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Layout(Protocol):
|
|
81
|
+
"""Where runs and items go below a root. Layouts are policies."""
|
|
82
|
+
|
|
83
|
+
def run_name(self, root: Path) -> str: ...
|
|
84
|
+
|
|
85
|
+
def item_name(self, run: Path, name: str) -> str: ...
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class FlatLayout:
|
|
89
|
+
"""The default: ``{root}/{run}/{item}``, items side by side."""
|
|
90
|
+
|
|
91
|
+
def run_name(self, root: Path) -> str:
|
|
92
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
93
|
+
|
|
94
|
+
def item_name(self, run: Path, name: str) -> str:
|
|
95
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class PytestLayout:
|
|
99
|
+
"""Example policy: pytest's ``pytest-{N}/{testname}{M}`` layout."""
|
|
100
|
+
|
|
101
|
+
def run_name(self, root: Path) -> str:
|
|
102
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
103
|
+
|
|
104
|
+
def item_name(self, run: Path, name: str) -> str:
|
|
105
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Run:
|
|
109
|
+
"""One invocation's folder. Several processes may join the same run."""
|
|
110
|
+
|
|
111
|
+
path: Path
|
|
112
|
+
id: str
|
|
113
|
+
|
|
114
|
+
def item(self, name: str) -> Path:
|
|
115
|
+
"""Create a new folder for ``name`` directly in the run folder."""
|
|
116
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
117
|
+
|
|
118
|
+
def process_folder(self, name: str) -> Path:
|
|
119
|
+
"""The folder of one process in this run, named by whoever manages
|
|
120
|
+
the processes (for pytest-xdist, the controller names its workers).
|
|
121
|
+
|
|
122
|
+
Unlike ``item``, the name is used exactly, and asking twice gives the
|
|
123
|
+
same folder. This is what ``getbasetemp()`` returns under pytest, so
|
|
124
|
+
``getbasetemp().parent`` stays the run's shared folder.
|
|
125
|
+
"""
|
|
126
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
127
|
+
|
|
128
|
+
def finish_item(self, path: Path, outcome: Outcome) -> None:
|
|
129
|
+
"""Report an item's whole outcome; retention decides its fate."""
|
|
130
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
131
|
+
|
|
132
|
+
def place(self, destination: Path) -> AbstractContextManager[Path]:
|
|
133
|
+
"""Give a staging path; move it to ``destination`` atomically on
|
|
134
|
+
success, discard it on error."""
|
|
135
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
136
|
+
|
|
137
|
+
def close(self) -> PruneReport:
|
|
138
|
+
"""Release this process's hold on the run and apply retention."""
|
|
139
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
140
|
+
|
|
141
|
+
def __enter__(self) -> Self:
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
def __exit__(self, *exc_info: object) -> None:
|
|
145
|
+
self.close()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class Root:
|
|
149
|
+
"""The folder that holds one project's runs."""
|
|
150
|
+
|
|
151
|
+
path: Path
|
|
152
|
+
|
|
153
|
+
def __init__(
|
|
154
|
+
self,
|
|
155
|
+
path: Path,
|
|
156
|
+
*,
|
|
157
|
+
retention: Retention = _DEFAULT_RETENTION,
|
|
158
|
+
layout: Layout | None = None,
|
|
159
|
+
) -> None:
|
|
160
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
161
|
+
|
|
162
|
+
@classmethod
|
|
163
|
+
def for_project(
|
|
164
|
+
cls,
|
|
165
|
+
project: str,
|
|
166
|
+
*,
|
|
167
|
+
temproot: Path | None = None,
|
|
168
|
+
retention: Retention = _DEFAULT_RETENTION,
|
|
169
|
+
layout: Layout | None = None,
|
|
170
|
+
) -> Root:
|
|
171
|
+
"""The default root for ``project`` under the system temp folder.
|
|
172
|
+
|
|
173
|
+
The root is ``{temproot}/cot.tmppath-{user}/{project}``, with the
|
|
174
|
+
user as the uid on POSIX and the user name on Windows. The per-user
|
|
175
|
+
folder is private and owner-checked, so another user cannot block or
|
|
176
|
+
read a project's runs by creating its folder first. Touches nothing
|
|
177
|
+
on disk until the first run starts.
|
|
178
|
+
"""
|
|
179
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
180
|
+
|
|
181
|
+
def start_run(self) -> Run:
|
|
182
|
+
"""Create a new run folder, locked by this process."""
|
|
183
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
184
|
+
|
|
185
|
+
def join_run(self, run_id: str) -> Run:
|
|
186
|
+
"""Join a run another process started, sharing its folder."""
|
|
187
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
188
|
+
|
|
189
|
+
@classmethod
|
|
190
|
+
def all_projects(cls, *, temproot: Path | None = None) -> tuple[Root, ...]:
|
|
191
|
+
"""Every project root of the current user under the default location."""
|
|
192
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
193
|
+
|
|
194
|
+
def plan_prune(self, *, older_than: float | None = None) -> PrunePlan:
|
|
195
|
+
"""What a prune would remove, without removing anything.
|
|
196
|
+
|
|
197
|
+
``older_than`` (seconds) also selects runs unused for that long,
|
|
198
|
+
whatever retention says. Live runs are never selected.
|
|
199
|
+
"""
|
|
200
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
201
|
+
|
|
202
|
+
def prune(self) -> PruneReport:
|
|
203
|
+
"""Remove runs that retention no longer keeps and whose owners are
|
|
204
|
+
gone: ``plan_prune().apply()``."""
|
|
205
|
+
raise NotImplementedError(_NOT_BUILT)
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Opt-in: replace pytest's tmp fixtures with cot.tmppath.
|
|
2
|
+
|
|
3
|
+
Enable it per project with::
|
|
4
|
+
|
|
5
|
+
[pytest]
|
|
6
|
+
addopts = -p cot.tmppath.overtake_pytest
|
|
7
|
+
|
|
8
|
+
Loaded with ``-p``, it unregisters pytest's ``tmpdir`` plugin and provides
|
|
9
|
+
``tmp_path``, ``tmp_path_factory``, ``tmpdir`` and ``tmpdir_factory`` itself.
|
|
10
|
+
pytest's ``tmp_path_retention_count`` and ``tmp_path_retention_policy``
|
|
11
|
+
settings keep working, and pytest-xdist workers join the controller's run.
|
|
12
|
+
``--basetemp`` must name a folder that does not exist yet; it is created and
|
|
13
|
+
nothing under it is ever deleted. See docs/pytest-replacement.md.
|
|
14
|
+
|
|
15
|
+
This module imports pytest; the rest of cot.tmppath never does.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import stat
|
|
23
|
+
import time
|
|
24
|
+
import warnings
|
|
25
|
+
from collections.abc import Generator
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
import pytest
|
|
30
|
+
from _pytest.compat import legacy_path
|
|
31
|
+
|
|
32
|
+
from ._api import KEEP_EVERYTHING, Outcome, Retention, Root, Run
|
|
33
|
+
|
|
34
|
+
_RUN_ID = "cot_tmppath_run"
|
|
35
|
+
_ROOT = "cot_tmppath_root"
|
|
36
|
+
_PROCESS = "cot_tmppath_process"
|
|
37
|
+
_item_path = pytest.StashKey[Path]()
|
|
38
|
+
_item_failed = pytest.StashKey[bool]()
|
|
39
|
+
_state = pytest.StashKey["_State"]()
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def pytest_addoption(parser: pytest.Parser, pluginmanager: Any) -> None:
|
|
43
|
+
tmpdir = pluginmanager.get_plugin("tmpdir")
|
|
44
|
+
if tmpdir is not None:
|
|
45
|
+
# The tmpdir plugin already registered the retention ini options, so
|
|
46
|
+
# existing configurations stay valid under --strict-config.
|
|
47
|
+
pluginmanager.unregister(tmpdir)
|
|
48
|
+
else:
|
|
49
|
+
# Blocked by the user with -p no:tmpdir: its ini options are missing.
|
|
50
|
+
parser.addini(
|
|
51
|
+
"tmp_path_retention_count",
|
|
52
|
+
help="How many runs to keep (cot.tmppath).",
|
|
53
|
+
default="3",
|
|
54
|
+
)
|
|
55
|
+
parser.addini(
|
|
56
|
+
"tmp_path_retention_policy",
|
|
57
|
+
help="Which item folders to keep: all/failed/none (cot.tmppath).",
|
|
58
|
+
default="all",
|
|
59
|
+
)
|
|
60
|
+
pluginmanager.set_blocked("tmpdir")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _retention(config: pytest.Config) -> Retention:
|
|
64
|
+
count = int(config.getini("tmp_path_retention_count"))
|
|
65
|
+
policy = config.getini("tmp_path_retention_policy")
|
|
66
|
+
if count < 0 or policy not in ("all", "failed", "none"):
|
|
67
|
+
msg = (
|
|
68
|
+
"tmp_path_retention_count must be >= 0 and tmp_path_retention_policy"
|
|
69
|
+
f" all, failed or none; got {count!r} and {policy!r}"
|
|
70
|
+
)
|
|
71
|
+
raise pytest.UsageError(msg)
|
|
72
|
+
return Retention(
|
|
73
|
+
keep_failed_only=policy == "failed",
|
|
74
|
+
keep_runs=0 if policy == "none" else count,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class _State:
|
|
79
|
+
"""The run, started or joined on first use, never at configure time."""
|
|
80
|
+
|
|
81
|
+
def __init__(self, config: pytest.Config) -> None:
|
|
82
|
+
self._config = config
|
|
83
|
+
self._run: Run | None = None
|
|
84
|
+
|
|
85
|
+
def _root(self) -> Root:
|
|
86
|
+
retention = _retention(self._config)
|
|
87
|
+
worker = getattr(self._config, "workerinput", None)
|
|
88
|
+
if worker is not None:
|
|
89
|
+
return Root(Path(worker[_ROOT]), retention=retention)
|
|
90
|
+
basetemp = self._config.option.basetemp
|
|
91
|
+
if basetemp:
|
|
92
|
+
# a folder the user named is never pruned, whatever retention says
|
|
93
|
+
return Root(Path(basetemp), retention=KEEP_EVERYTHING)
|
|
94
|
+
temproot = os.environ.get("PYTEST_DEBUG_TEMPROOT")
|
|
95
|
+
return Root.for_project(
|
|
96
|
+
self._config.rootpath.name,
|
|
97
|
+
temproot=Path(temproot) if temproot else None,
|
|
98
|
+
retention=retention,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def process(self) -> str:
|
|
103
|
+
worker = getattr(self._config, "workerinput", None)
|
|
104
|
+
return str(worker[_PROCESS]) if worker is not None else "main"
|
|
105
|
+
|
|
106
|
+
@property
|
|
107
|
+
def run(self) -> Run:
|
|
108
|
+
if self._run is None:
|
|
109
|
+
root = self._root()
|
|
110
|
+
worker = getattr(self._config, "workerinput", None)
|
|
111
|
+
if worker is not None:
|
|
112
|
+
self._run = root.join_run(worker[_RUN_ID])
|
|
113
|
+
else:
|
|
114
|
+
self._run = root.start_run()
|
|
115
|
+
self._config.add_cleanup(self._close)
|
|
116
|
+
return self._run
|
|
117
|
+
|
|
118
|
+
def _close(self) -> None:
|
|
119
|
+
assert self._run is not None
|
|
120
|
+
for path, reason in self._run.close().failed:
|
|
121
|
+
# reported, never swallowed (docs/goals.md, G4)
|
|
122
|
+
warnings.warn(
|
|
123
|
+
pytest.PytestWarning(f"cot.tmppath could not remove {path}: {reason}"),
|
|
124
|
+
stacklevel=1,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# An empty folder this young was made by whoever started pytest, for this
|
|
129
|
+
# run, the way pytester's runpytest_subprocess does it.
|
|
130
|
+
_FRESH_SECONDS = 10.0
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _check_basetemp(config: pytest.Config) -> None:
|
|
134
|
+
basetemp = config.option.basetemp
|
|
135
|
+
# xdist hands workers the controller's options, by then the folder exists
|
|
136
|
+
if not basetemp or hasattr(config, "workerinput"):
|
|
137
|
+
return
|
|
138
|
+
path = Path(basetemp)
|
|
139
|
+
try:
|
|
140
|
+
info = path.lstat()
|
|
141
|
+
except FileNotFoundError:
|
|
142
|
+
return
|
|
143
|
+
# lstat, never stat: a symlink to a fresh empty folder is not fresh
|
|
144
|
+
owned = not hasattr(os, "getuid") or info.st_uid == os.getuid()
|
|
145
|
+
fresh = (
|
|
146
|
+
stat.S_ISDIR(info.st_mode)
|
|
147
|
+
and owned
|
|
148
|
+
and time.time() - info.st_mtime < _FRESH_SECONDS
|
|
149
|
+
and not any(path.iterdir())
|
|
150
|
+
)
|
|
151
|
+
if fresh:
|
|
152
|
+
config.issue_config_time_warning(
|
|
153
|
+
pytest.PytestWarning(
|
|
154
|
+
f"--basetemp={basetemp} already exists; using it because it is"
|
|
155
|
+
f" empty and less than {_FRESH_SECONDS:.0f}s old. Pass a path"
|
|
156
|
+
" that does not exist yet."
|
|
157
|
+
),
|
|
158
|
+
stacklevel=2,
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
msg = (
|
|
162
|
+
f"--basetemp={basetemp} already exists. cot.tmppath only creates a"
|
|
163
|
+
" new folder there and never deletes one; remove it yourself or"
|
|
164
|
+
" name a path that does not exist yet."
|
|
165
|
+
)
|
|
166
|
+
raise pytest.UsageError(msg)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def pytest_configure(config: pytest.Config) -> None:
|
|
170
|
+
_check_basetemp(config)
|
|
171
|
+
config.stash[_state] = _State(config)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@pytest.hookimpl(optionalhook=True)
|
|
175
|
+
def pytest_configure_node(node: Any) -> None:
|
|
176
|
+
run = node.config.stash[_state].run
|
|
177
|
+
node.workerinput[_ROOT] = str(run.path.parent)
|
|
178
|
+
node.workerinput[_RUN_ID] = run.id
|
|
179
|
+
# the manager names each process's folder, workers never pick their own
|
|
180
|
+
node.workerinput[_PROCESS] = node.gateway.id
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class TempPathFactory:
|
|
184
|
+
"""What ``tmp_path_factory`` returns.
|
|
185
|
+
|
|
186
|
+
It offers the part of pytest's factory that pytester, plugins and
|
|
187
|
+
conftests use: ``mktemp`` and ``getbasetemp``.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
def __init__(self, state: _State) -> None:
|
|
191
|
+
self._state = state
|
|
192
|
+
|
|
193
|
+
def getbasetemp(self) -> Path:
|
|
194
|
+
# per process, so getbasetemp().parent is the run's shared folder,
|
|
195
|
+
# as it is for pytest under xdist
|
|
196
|
+
return self._state.run.process_folder(self._state.process)
|
|
197
|
+
|
|
198
|
+
def mktemp(self, basename: str, numbered: bool = True) -> Path:
|
|
199
|
+
# Every item gets a unique name; numbered=False cannot promise the
|
|
200
|
+
# exact name in a run that other processes share.
|
|
201
|
+
return self._state.run.item(basename)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.fixture(scope="session")
|
|
205
|
+
def tmp_path_factory(request: pytest.FixtureRequest) -> TempPathFactory:
|
|
206
|
+
return TempPathFactory(request.config.stash[_state])
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class TempdirFactory:
|
|
210
|
+
"""What ``tmpdir_factory`` returns: the same, with ``py.path`` results."""
|
|
211
|
+
|
|
212
|
+
def __init__(self, factory: TempPathFactory) -> None:
|
|
213
|
+
self._factory = factory
|
|
214
|
+
|
|
215
|
+
def getbasetemp(self) -> Any:
|
|
216
|
+
return legacy_path(self._factory.getbasetemp())
|
|
217
|
+
|
|
218
|
+
def mktemp(self, basename: str, numbered: bool = True) -> Any:
|
|
219
|
+
return legacy_path(self._factory.mktemp(basename, numbered))
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
@pytest.fixture(scope="session")
|
|
223
|
+
def tmpdir_factory(tmp_path_factory: TempPathFactory) -> TempdirFactory:
|
|
224
|
+
return TempdirFactory(tmp_path_factory)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@pytest.fixture
|
|
228
|
+
def tmp_path(request: pytest.FixtureRequest, tmp_path_factory: TempPathFactory) -> Path:
|
|
229
|
+
path = tmp_path_factory.mktemp(re.sub(r"\W", "_", request.node.name))
|
|
230
|
+
request.node.stash[_item_path] = path
|
|
231
|
+
return path
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@pytest.fixture
|
|
235
|
+
def tmpdir(tmp_path: Path) -> Any:
|
|
236
|
+
return legacy_path(tmp_path)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
@pytest.hookimpl(wrapper=True)
|
|
240
|
+
def pytest_runtest_makereport(
|
|
241
|
+
item: pytest.Item, call: pytest.CallInfo[None]
|
|
242
|
+
) -> Generator[None, pytest.TestReport, pytest.TestReport]:
|
|
243
|
+
report = yield
|
|
244
|
+
failed = item.stash.get(_item_failed, False) or report.failed
|
|
245
|
+
item.stash[_item_failed] = failed
|
|
246
|
+
# The fixture's own teardown runs before this report, so only here is
|
|
247
|
+
# the whole outcome (setup, call and teardown) known.
|
|
248
|
+
if report.when == "teardown" and _item_path in item.stash:
|
|
249
|
+
outcome = Outcome.FAILED if failed else Outcome.PASSED
|
|
250
|
+
item.config.stash[_state].run.finish_item(item.stash[_item_path], outcome)
|
|
251
|
+
return report
|
cot/tmppath/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: cot.tmppath
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Hardened, fast management of related temporary folders
|
|
5
|
+
Project-URL: Source, https://github.com/cogs-of-testing/cot.tmppath
|
|
6
|
+
Author-email: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Classifier: Development Status :: 1 - Planning
|
|
10
|
+
Classifier: Framework :: Pytest
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# cot.tmppath
|
|
23
|
+
|
|
24
|
+
A hardened, fast building block for related temporary folders: one folder
|
|
25
|
+
per run, items side by side inside it, kept or removed by policy, safe with
|
|
26
|
+
concurrent processes. For test runners (a pytest binding) and for
|
|
27
|
+
cot.runsomewhere's bootstrap staging and worker scratch.
|
|
28
|
+
|
|
29
|
+
Not built yet. The goals are in [docs/goals.md](docs/goals.md) and the
|
|
30
|
+
background in [docs/research.md](docs/research.md). `src/cot/tmppath` holds
|
|
31
|
+
the intended API as stubs that raise `NotImplementedError`, and `testing/`
|
|
32
|
+
states the goals as tests, marked xfail until each behaviour lands.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
uv run pytest -q
|
|
36
|
+
uv run mypy
|
|
37
|
+
uv run --group bench pytest benchmarks
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
pytest projects can opt in to having pytest's `tmp_path`, `tmp_path_factory`,
|
|
41
|
+
`tmpdir` and `tmpdir_factory` replaced:
|
|
42
|
+
|
|
43
|
+
```ini
|
|
44
|
+
[pytest]
|
|
45
|
+
addopts = -p cot.tmppath.overtake_pytest
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
How and why is in [docs/pytest-replacement.md](docs/pytest-replacement.md).
|
|
49
|
+
|
|
50
|
+
Old runs can be removed by hand; it lists them and asks before removing:
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
python -m cot.tmppath prune --all-projects --older-than 7d
|
|
54
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
cot/tmppath/__init__.py,sha256=kuKNVfx_DbvhHRHxYeaNsW_Oq6qPOldXd1_yna1wVe0,492
|
|
2
|
+
cot/tmppath/__main__.py,sha256=WidLehvXI0Q-yAD0Xb38WZ-f_Jl0lsq8aOL7M7zAEgk,4038
|
|
3
|
+
cot/tmppath/_api.py,sha256=6CCJGdgASVotS4hCu2kH-LWHeLos_0ZMF5gvwb_6Z1A,6568
|
|
4
|
+
cot/tmppath/overtake_pytest.py,sha256=mTWKRXGyHkPgbtohBLqcn7PteNXVWkncB3NSgPiJ4JA,8684
|
|
5
|
+
cot/tmppath/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
cot_tmppath-0.1.0.dist-info/METADATA,sha256=ObujnRq2R19Huq5cnVOmfqqCUsTkYvpG0tVly8Sqn9I,1891
|
|
7
|
+
cot_tmppath-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
8
|
+
cot_tmppath-0.1.0.dist-info/entry_points.txt,sha256=zgYeyXOz6EGmEqwr6gPrscBV2cL2EOu3o2Us34fxigI,58
|
|
9
|
+
cot_tmppath-0.1.0.dist-info/licenses/LICENSE,sha256=ulVKrMFItPd1VE61kn5I5vjx5Ix4VCYeikZlwHcF6YQ,1085
|
|
10
|
+
cot_tmppath-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ronny Pfannschmidt
|
|
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.
|