cot.tmppath 0.1.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.
@@ -0,0 +1,11 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: github-actions
4
+ directory: /
5
+ schedule:
6
+ interval: monthly
7
+ cooldown:
8
+ default-days: 7
9
+ groups:
10
+ actions:
11
+ patterns: ["*"]
@@ -0,0 +1,47 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ workflow_dispatch:
8
+
9
+ permissions: {}
10
+
11
+ concurrency:
12
+ group: ${{ github.workflow }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ jobs:
16
+ test:
17
+ runs-on: ${{ matrix.os }}
18
+ strategy:
19
+ fail-fast: false
20
+ matrix:
21
+ os: [ubuntu-latest, macos-latest, windows-latest]
22
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
23
+ steps:
24
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
25
+ with:
26
+ persist-credentials: false
27
+ fetch-depth: 0 # hatch-vcs derives the version from tags
28
+
29
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
30
+ with:
31
+ python-version: ${{ matrix.python-version }}
32
+
33
+ - run: uv run --frozen pytest -q
34
+
35
+ lint:
36
+ runs-on: ubuntu-latest
37
+ steps:
38
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
39
+ with:
40
+ persist-credentials: false
41
+ fetch-depth: 0
42
+
43
+ - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
44
+ with:
45
+ python-version: "3.14"
46
+
47
+ - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1
@@ -0,0 +1,56 @@
1
+ name: Release
2
+
3
+ on:
4
+ push:
5
+ tags: ["v*"]
6
+
7
+ permissions: {}
8
+
9
+ jobs:
10
+ build:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
14
+ with:
15
+ persist-credentials: false
16
+ fetch-depth: 0 # hatch-vcs derives the version from tags
17
+
18
+ - uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
19
+ with:
20
+ python-version: "3.14"
21
+ # A release builds from a clean slate: a restored cache is one more
22
+ # thing that could influence what ends up on PyPI.
23
+ enable-cache: false
24
+
25
+ # The tag is what the version comes from, so a release must not ship a
26
+ # tree that fails its own suite.
27
+ - run: uv run --frozen pytest -q
28
+
29
+ - run: uv build
30
+
31
+ # A wheel that cannot be imported from a clean environment is the one
32
+ # failure mode the test suite cannot see.
33
+ - name: smoke-test the built wheel
34
+ run: |
35
+ uv run --isolated --no-project --with dist/*.whl \
36
+ python -c "import cot.tmppath; print(cot.tmppath.__all__)"
37
+
38
+ - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
39
+ with:
40
+ name: dist
41
+ path: dist/
42
+ if-no-files-found: error
43
+
44
+ publish:
45
+ needs: build
46
+ runs-on: ubuntu-latest
47
+ environment: pypi
48
+ permissions:
49
+ id-token: write # PyPI trusted publishing
50
+ steps:
51
+ - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
52
+ with:
53
+ name: dist
54
+ path: dist/
55
+
56
+ - uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # v1.14.2
@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ .venv/
3
+ .mypy_cache/
4
+ .pytest_cache/
5
+ .ruff_cache/
6
+ *.egg-info/
7
+ dist/
@@ -0,0 +1,28 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: v6.0.0
4
+ hooks:
5
+ - id: check-toml
6
+ - id: check-yaml
7
+ - id: end-of-file-fixer
8
+ - id: trailing-whitespace
9
+
10
+ - repo: https://github.com/astral-sh/ruff-pre-commit
11
+ rev: v0.16.9
12
+ hooks:
13
+ - id: ruff-check
14
+ args: [ --fix, --show-fixes ]
15
+ - id: ruff-format
16
+
17
+ - repo: https://github.com/pre-commit/mirrors-mypy
18
+ rev: v2.3.1
19
+ hooks:
20
+ - id: mypy
21
+ # checks what [tool.mypy] names, not the staged files alone
22
+ pass_filenames: false
23
+ additional_dependencies: [ pytest ]
24
+
25
+ - repo: https://github.com/zizmorcore/zizmor-pre-commit
26
+ rev: v1.30.1
27
+ hooks:
28
+ - id: zizmor
@@ -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.
@@ -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,33 @@
1
+ # cot.tmppath
2
+
3
+ A hardened, fast building block for related temporary folders: one folder
4
+ per run, items side by side inside it, kept or removed by policy, safe with
5
+ concurrent processes. For test runners (a pytest binding) and for
6
+ cot.runsomewhere's bootstrap staging and worker scratch.
7
+
8
+ Not built yet. The goals are in [docs/goals.md](docs/goals.md) and the
9
+ background in [docs/research.md](docs/research.md). `src/cot/tmppath` holds
10
+ the intended API as stubs that raise `NotImplementedError`, and `testing/`
11
+ states the goals as tests, marked xfail until each behaviour lands.
12
+
13
+ ```bash
14
+ uv run pytest -q
15
+ uv run mypy
16
+ uv run --group bench pytest benchmarks
17
+ ```
18
+
19
+ pytest projects can opt in to having pytest's `tmp_path`, `tmp_path_factory`,
20
+ `tmpdir` and `tmpdir_factory` replaced:
21
+
22
+ ```ini
23
+ [pytest]
24
+ addopts = -p cot.tmppath.overtake_pytest
25
+ ```
26
+
27
+ How and why is in [docs/pytest-replacement.md](docs/pytest-replacement.md).
28
+
29
+ Old runs can be removed by hand; it lists them and asks before removing:
30
+
31
+ ```bash
32
+ python -m cot.tmppath prune --all-projects --older-than 7d
33
+ ```
@@ -0,0 +1,108 @@
1
+ """Benchmarks against pytest's own tmp_path machinery (docs/goals.md, G3).
2
+
3
+ Each scenario runs once with pytest's ``TempPathFactory`` and once with
4
+ cot.tmppath. The cot.tmppath side is xfail until the core is built.
5
+
6
+ uv run --group bench pytest benchmarks
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import shutil
12
+ from pathlib import Path
13
+
14
+ import pytest
15
+ from _pytest.tmpdir import TempPathFactory
16
+
17
+ from cot.tmppath import Outcome, Retention, Root
18
+
19
+ not_built = pytest.mark.xfail(
20
+ raises=NotImplementedError, reason="core not built yet (docs/goals.md)"
21
+ )
22
+
23
+ # pytest scans the base folder on every mktemp, so cost grows with siblings
24
+ ITEMS = [100, 1000]
25
+
26
+
27
+ def _pytest_factory(base: Path) -> TempPathFactory:
28
+ return TempPathFactory(
29
+ given_basetemp=base,
30
+ retention_count=3,
31
+ retention_policy="all",
32
+ trace=lambda *args: None,
33
+ _ispytest=True,
34
+ )
35
+
36
+
37
+ class _Fresh:
38
+ """Hands out a new, empty folder per benchmark round."""
39
+
40
+ def __init__(self, base: Path) -> None:
41
+ self._base = base
42
+ self._count = 0
43
+
44
+ def __call__(self) -> Path:
45
+ self._count += 1
46
+ return self._base / f"round{self._count}"
47
+
48
+
49
+ @pytest.fixture
50
+ def fresh(tmp_path: Path) -> _Fresh:
51
+ return _Fresh(tmp_path)
52
+
53
+
54
+ @pytest.mark.parametrize("count", ITEMS)
55
+ def test_items_pytest(benchmark, fresh: _Fresh, count: int) -> None:
56
+ def make() -> None:
57
+ factory = _pytest_factory(fresh())
58
+ for _ in range(count):
59
+ factory.mktemp("test_something_parametrized_", numbered=True)
60
+
61
+ benchmark.pedantic(make, rounds=5, iterations=1)
62
+
63
+
64
+ @not_built
65
+ @pytest.mark.parametrize("count", ITEMS)
66
+ def test_items_cot(benchmark, fresh: _Fresh, count: int) -> None:
67
+ def make() -> None:
68
+ with Root(fresh()).start_run() as run:
69
+ for _ in range(count):
70
+ run.item("test_something_parametrized_")
71
+
72
+ benchmark.pedantic(make, rounds=5, iterations=1)
73
+
74
+
75
+ def _filled(path: Path) -> Path:
76
+ path.mkdir(parents=True)
77
+ for index in range(200):
78
+ (path / f"f{index}").write_bytes(b"x" * 512)
79
+ return path
80
+
81
+
82
+ def test_removal_pytest(benchmark, fresh: _Fresh) -> None:
83
+ # what tmp_path's teardown does under tmp_path_retention_policy=failed
84
+ def setup() -> tuple[tuple[Path], dict[str, object]]:
85
+ return (_filled(fresh() / "item"),), {}
86
+
87
+ benchmark.pedantic(
88
+ lambda item: shutil.rmtree(item, ignore_errors=True),
89
+ setup=setup,
90
+ rounds=20,
91
+ )
92
+
93
+
94
+ @not_built
95
+ def test_removal_cot(benchmark, fresh: _Fresh) -> None:
96
+ root = Root(fresh(), retention=Retention(keep_failed_only=True))
97
+ with root.start_run() as run:
98
+
99
+ def setup() -> tuple[tuple[Path], dict[str, object]]:
100
+ item = run.item("item")
101
+ _filled(item / "data")
102
+ return (item,), {}
103
+
104
+ benchmark.pedantic(
105
+ lambda item: run.finish_item(item, Outcome.PASSED),
106
+ setup=setup,
107
+ rounds=20,
108
+ )
@@ -0,0 +1,154 @@
1
+ # Assessment: hardening, pytest policies, cleanup
2
+
3
+ *Written by Claude via Claude Code from Ronny's brief; Ronny prompted it.*
4
+
5
+ Status as of 2026-10-05. Checked against `main`, pytest 9.1.1 and
6
+ pytest-xdist 3.8. **(verified)** means it was run, not read.
7
+
8
+ ## Summary
9
+
10
+ - **The core implements no hardening yet.** Everything in `_api.py` raises
11
+ `NotImplementedError`. What exists is a specification: 4 hardening tests
12
+ in `testing/test_hardening.py`, all xfail. Today cot.tmppath is not safer
13
+ than pytest, it is a plan to be.
14
+ - **The specification has real gaps** (below), the largest being that the
15
+ default root has no per-user part, which reproduces pytest's shared-`/tmp`
16
+ denial of service.
17
+ - **The pytest plugin is the only working code.** Its `--basetemp` check had
18
+ a symlink hole, now fixed; three compatibility gaps were fixed, and three
19
+ semantic differences from pytest remain open.
20
+ - **A cleanup command is worth having**, as a thin wrapper over
21
+ `Root.prune`, mainly for runsomewhere targets and for projects that are no
22
+ longer run. Recommendation below.
23
+
24
+ ## 1. Hardening
25
+
26
+ ### What the tests specify
27
+
28
+ | Property | Test | State |
29
+ |---|---|---|
30
+ | root, run and item folders are 0o700 | `test_folders_are_private` | xfail |
31
+ | a symlinked root is refused, target untouched | `test_symlinked_root_is_refused` | xfail |
32
+ | an existing folder we did not create is refused, not wiped | `test_existing_folder_it_did_not_create_is_refused_not_wiped` | xfail |
33
+ | a root owned by another user is refused | `test_root_owned_by_another_user_is_refused` | xfail, runs only as root |
34
+ | an existing `--basetemp` is refused, contents untouched | `test_existing_basetemp_is_refused` | **passes** |
35
+ | a symlink to a fresh empty folder is refused as `--basetemp` | `test_symlink_to_a_fresh_empty_folder_is_refused` | **passes** (fixed today) |
36
+
37
+ ### Gaps in the specification
38
+
39
+ These are not covered by any test and not decided in `goals.md`:
40
+
41
+ 1. **No per-user part in the default root** (decided: added, as
42
+ `{temproot}/cot.tmppath-{user}/{project}`, see goals G2). `Root.for_project(project)`
43
+ puts the root under the system temp folder by project name alone. On a
44
+ shared `/tmp`, user B pre-creating `/tmp/<project>` makes user A's runs
45
+ fail the ownership check: the same denial of service pytest accepts for
46
+ `pytest-of-{user}`. The default root needs the user in its name (uid on
47
+ POSIX), and the parent should be checked for the sticky bit when it is
48
+ world-writable.
49
+ 2. **TOCTOU between check and use is untested.** G1 promises `dir_fd` and
50
+ `O_NOFOLLOW`, but no test swaps a folder for a symlink between the check
51
+ and the `mkdir` of an item, or replaces a run folder while a run is live.
52
+ 3. **The plugin's `--basetemp` check is advisory.** It runs at configure
53
+ time; the folder is created on first use. Only the core creating the root
54
+ with an exclusive `mkdir` (and refusing on `EEXIST` unless it is the
55
+ accepted fresh folder) closes that window.
56
+ 4. **Windows has no equivalent of 0o700.** `chmod` there only toggles the
57
+ read-only flag. Protection rests on `%TEMP%` being per-user. Not stated in
58
+ the goals, not tested; a root outside the user profile is unprotected.
59
+ 5. **Lock liveness is unspecified at the edge cases**: pid reuse after a
60
+ reboot (hence the boot id in G5), and a lock on a filesystem shared
61
+ between hosts.
62
+ 6. **Read-only content.** Tests that `chmod` their files read-only made pytest
63
+ crash on the next run (pytest #5524). No test covers removal of read-only
64
+ files and folders, or of folders a test made unreadable.
65
+ 7. **The marker is unspecified.** "Only deletes what it created" depends on a
66
+ marker; its content, how it is checked, and that it cannot be planted by
67
+ another user (the ownership check covers that on POSIX) need writing down.
68
+
69
+ ## 2. pytest's policies and our mapping
70
+
71
+ What pytest does, observed with `count=2` over five runs with one passing and
72
+ one failing test, then one all-passing run **(verified)**:
73
+
74
+ | Policy | pytest keeps | All-passing run | Our mapping |
75
+ |---|---|---|---|
76
+ | `all` | last *count* runs, every test folder | kept, counts as a run | `keep_runs=count` |
77
+ | `failed` | last *count* runs, only failed test folders | whole run folder removed at session end | `keep_failed_only=True, keep_runs=count` |
78
+ | `none` | nothing, not even the current run after it ends | removed | `keep_runs=0` |
79
+
80
+ Where we differ:
81
+
82
+ 1. **`failed`: an all-passing run leaves an empty run folder.** pytest
83
+ removes it. We should remove a run that ends with no items, or the empty
84
+ folders pile up to *count*. Small; belongs in the core's `close()`.
85
+ 2. **`none`: whether `keep_runs=0` removes the current run at close is not
86
+ specified.** pytest does. Our concurrency test only says a *live* run
87
+ survives another process's prune. A test should pin that closing with
88
+ `keep_runs=0` removes the run.
89
+ 3. **Whole outcome, not `call`.** Deliberately different: we keep folders of
90
+ setup and teardown failures, pytest discards them
91
+ (`test_failed_policy_keeps_setup_and_teardown_failures`).
92
+ 4. **`--basetemp`.** Deliberately different: no wiping, no retention, refuse
93
+ an existing folder (issue #1 tracks the option's name).
94
+
95
+ Plugin compatibility, checked against pytest's `tmpdir.py` and
96
+ `legacypath.py`:
97
+
98
+ | pytest behaviour | Plugin | State |
99
+ |---|---|---|
100
+ | `tmpdir_factory` returns `py.path` objects | returned `Path` | **fixed today** |
101
+ | `PYTEST_DEBUG_TEMPROOT` overrides the temp root | ignored | **fixed today** |
102
+ | retention ini values validated, error on bad value | `UsageError` | ok |
103
+ | `mktemp(name, numbered=False)` creates exactly `name`, fails if it exists | always unique name | **open**: breaks code that relies on the exact name |
104
+ | `getbasetemp()` is per process; under xdist `getbasetemp().parent` is the shared run folder | the process's own folder, named by the controller | **fixed**: `getbasetemp().parent` is the run |
105
+ | `mktemp` rejects absolute and non-normalised names (#5686) | passed to `Run.item`, which flattens them | **open**: decide whether to reject like pytest |
106
+ | `tmp_path` is a resolved real path (#4653) | depends on the core | open, add to the core tests |
107
+ | test folder names truncated to 30 characters | full sanitized name, the core shortens to at most 64 | intended |
108
+
109
+ `getbasetemp()` now returns a per-process folder inside the run, named by
110
+ the manager (`Run.process_folder`), so `getbasetemp().parent` means "the
111
+ run" as it does with pytest under xdist.
112
+
113
+ ## 3. Do we need a cleanup command?
114
+
115
+ **Who cleans up today.** pytest removes old runs only when the same user runs
116
+ pytest again, and only in its own `pytest-of-{user}`. Our retention is the
117
+ same: it runs when a run closes, per project. So nothing ever removes:
118
+
119
+ - the runs of a project that is not run again;
120
+ - the runs left by crashed processes, until that project runs again and the
121
+ owner is known dead;
122
+ - runsomewhere's staging and scratch on a target host that is not visited
123
+ again, which the system's `/tmp` cleaning may or may not catch
124
+ (from memory, not checked here: systemd-tmpfiles typically ages `/tmp`
125
+ after about 10 days, macOS cleans `/var/folders` after a few days, and
126
+ containers and long-lived CI runners often never clean at all).
127
+
128
+ `Root.prune()` already exists in the API, so a command is a thin wrapper.
129
+
130
+ **Decided: yes, small.** Built as `python -m cot.tmppath prune` (also the
131
+ `cot-tmppath` script):
132
+
133
+ ```text
134
+ python -m cot.tmppath prune [ROOT ...] [--all-projects] [--older-than 7d]
135
+ [--dry-run | --delete-without-asking-i-have-read-the-dry-run]
136
+ ```
137
+
138
+ - By default it lists what it would remove and asks; only typing `yes`
139
+ removes anything. Without a terminal to ask on, it refuses.
140
+ - `--dry-run` only lists.
141
+ - Removing without asking takes a flag that is long on purpose, and
142
+ argparse abbreviations are off, so `--delete` does not expand to it
143
+ **(verified: argparse accepts such prefixes by default)**.
144
+ - It removes exactly the listed folders, each checked again first
145
+ (`PrunePlan.apply`).
146
+
147
+ - It applies exactly the library's rules: only folders carrying our marker,
148
+ owned by the current user, whose lock owner is dead; never anything else.
149
+ - `--all-projects` walks every project root under the user's default
150
+ location, which is the case retention cannot reach.
151
+ - runsomewhere can call `Root.prune` directly over its own connection; the
152
+ command is for people and cron.
153
+ - A pytest option (`--tmp-prune`) is not needed: the plugin prunes the
154
+ current project on every run already.