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.
@@ -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
+ ]
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cot-tmppath = cot.tmppath.__main__:main
@@ -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.