cot.tmppath 0.2.0__tar.gz → 0.3.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cot_tmppath-0.3.0/CHANGELOG.md +25 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/PKG-INFO +1 -1
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/core.md +9 -7
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/goals.md +3 -3
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/pytest-replacement.md +24 -26
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/_api.py +34 -7
- cot_tmppath-0.3.0/src/cot/tmppath/_owner.py +141 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/overtake_pytest.py +45 -57
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_concurrency.py +15 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_overtake_pytest.py +54 -31
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_retention.py +12 -0
- cot_tmppath-0.2.0/CHANGELOG.md +0 -13
- cot_tmppath-0.2.0/src/cot/tmppath/_owner.py +0 -92
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/dependabot.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/workflows/ci.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/workflows/release-proposal.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/workflows/release-tag.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.github/workflows/release.yml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.gitignore +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/.pre-commit-config.yaml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/LICENSE +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/README.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/benchmarks/test_bench.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/changelog.d/README.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/changelog.d/template.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/assessment.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/releasing.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/docs/research.md +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/pyproject.toml +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/__init__.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/__main__.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/_fs.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/src/cot/tmppath/py.typed +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/conftest.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_cli.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_core.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_hardening.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_layout.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_pytest_replacement.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/testing/test_speed.py +0 -0
- {cot_tmppath-0.2.0 → cot_tmppath-0.3.0}/uv.lock +0 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
<!-- towncrier release notes start -->
|
|
4
|
+
|
|
5
|
+
## 0.3.0 (2026-10-08)
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- The pytest binding replaces pytest's basetemp handling as a whole: `getbasetemp()` is the run folder (an xdist worker's own folder in the run), `tmp_path` and `mktemp()` folders are made inside it, and `config._tmp_path_factory` is the cot.tmppath factory. `--basetemp` names a root that holds the runs; it is reused when cot.tmppath made it, so repeated `pytester.runpytest()` calls work, and a folder cot.tmppath did not make is refused and never touched. New: `Root.ensure()`, and `Run.item(name, process=...)`.
|
|
10
|
+
|
|
11
|
+
## 0.2.1 (2026-10-07)
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- A crashed run whose pid was reused by another process is collected: holder files record the process start time on Linux and Windows. Before, the run counted as alive until that other process ended. macOS still relies on the pid alone.
|
|
16
|
+
|
|
17
|
+
## 0.2.0 (2026-10-07)
|
|
18
|
+
|
|
19
|
+
### Removed
|
|
20
|
+
|
|
21
|
+
- The pytest plugin no longer provides the `py.path` fixtures `tmpdir` and `tmpdir_factory`; use `tmp_path` and `tmp_path_factory`.
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Build the core: `Root`, `Run`, the flat and pytest layouts, retention, pruning, liveness and atomic placement now work instead of raising `NotImplementedError`, so `-p cot.tmppath.overtake_pytest` provides working `tmp_path` and `tmp_path_factory` fixtures.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: cot.tmppath
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Hardened, fast management of related temporary folders
|
|
5
5
|
Project-URL: Source, https://github.com/cogs-of-testing/cot.tmppath
|
|
6
6
|
Author-email: Ronny Pfannschmidt <opensource@ronnypfannschmidt.de>
|
|
@@ -16,7 +16,8 @@ What `src/cot/tmppath/_api.py` does to keep the goals in
|
|
|
16
16
|
.cot-trash-{pid}-{token}/ removed items of one process, until it closes
|
|
17
17
|
test_foo/ items, side by side
|
|
18
18
|
test_foo-1/
|
|
19
|
-
gw0/ process folders
|
|
19
|
+
gw0/ process folders (one per xdist worker)
|
|
20
|
+
test_bar/ items made with process="gw0"
|
|
20
21
|
```
|
|
21
22
|
|
|
22
23
|
Every name starting with `.cot-` is the library's; item names never do.
|
|
@@ -58,13 +59,14 @@ The default root adds two levels above this:
|
|
|
58
59
|
## Liveness and retention (G4, G5)
|
|
59
60
|
|
|
60
61
|
- A process that starts or joins a run writes a holder file with its pid,
|
|
61
|
-
host
|
|
62
|
-
live while any holder may be alive: same host and boot,
|
|
63
|
-
|
|
62
|
+
host, Linux boot id and process start time, and removes it when it closes
|
|
63
|
+
the run. A run is live while any holder may be alive: same host and boot,
|
|
64
|
+
the pid exists, and the process with that pid started when the holder
|
|
65
|
+
says. A holder from another host always counts as alive, so a run is
|
|
64
66
|
never collected on a guess.
|
|
65
|
-
-
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
- The start time comes from `/proc` on Linux and `GetProcessTimes` on
|
|
68
|
+
Windows. **Open:** macOS has neither, so there a pid reused by an
|
|
69
|
+
unrelated process keeps a crashed run alive until that process ends.
|
|
68
70
|
- Closing a run applies retention to the whole root: the newest
|
|
69
71
|
`keep_runs` runs stay, older ones that are not live go, and so do runs
|
|
70
72
|
older than `max_age`. The run being closed counts as the newest. A
|
|
@@ -107,9 +107,9 @@ Time is a goal, not an afterthought, and it is measured.
|
|
|
107
107
|
which today discards the folders of tests that error in setup or teardown.
|
|
108
108
|
- Retention works with any root a caller gives the library, which
|
|
109
109
|
`--basetemp` cannot do
|
|
110
|
-
([#10829](https://github.com/pytest-dev/pytest/issues/10829)).
|
|
111
|
-
`--basetemp`
|
|
112
|
-
|
|
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
|
|
76
|
-
replacement reads `config.option.basetemp`
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
119
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
`getbasetemp().parent` is the run
|
|
131
|
-
|
|
132
|
-
|
|
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`
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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``
|
|
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(
|
|
293
|
+
candidate = _check_name(layout.item_name(folder.path, name), "item")
|
|
291
294
|
try:
|
|
292
|
-
return
|
|
295
|
+
return folder.mkdir(candidate)
|
|
293
296
|
except FileExistsError:
|
|
294
297
|
continue
|
|
295
|
-
msg = f"no free item name for {name!r} in {
|
|
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
|
|
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
|
-
|
|
348
|
+
folder.rename(path.name, secrets.token_hex(8), into=self._trash)
|
|
336
349
|
|
|
337
350
|
@contextmanager
|
|
338
351
|
def place(self, destination: Path) -> Iterator[Path]:
|
|
@@ -362,6 +375,9 @@ class Run:
|
|
|
362
375
|
holders.unlink(self._token)
|
|
363
376
|
except FileNotFoundError:
|
|
364
377
|
pass
|
|
378
|
+
for process in self._processes.values():
|
|
379
|
+
process.close()
|
|
380
|
+
self._processes.clear()
|
|
365
381
|
if self._trash is not None:
|
|
366
382
|
self._trash.close()
|
|
367
383
|
reason = self._dir.rmtree(_TRASH + self._token)
|
|
@@ -551,6 +567,17 @@ class Root:
|
|
|
551
567
|
folder = child
|
|
552
568
|
return folder
|
|
553
569
|
|
|
570
|
+
def ensure(self) -> None:
|
|
571
|
+
"""Create the root, or check an existing one, without starting a run.
|
|
572
|
+
|
|
573
|
+
Raises ``UnsafeRootError`` for anything the root may not use: a
|
|
574
|
+
symlink, another user's folder, or a non-empty folder cot.tmppath
|
|
575
|
+
did not create. Nothing in such a folder is touched.
|
|
576
|
+
"""
|
|
577
|
+
folder = self._open(create=True)
|
|
578
|
+
assert folder is not None
|
|
579
|
+
folder.close()
|
|
580
|
+
|
|
554
581
|
def start_run(self) -> Run:
|
|
555
582
|
"""Create a new run folder, held by this process."""
|
|
556
583
|
root = self._open(create=True)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Who holds a run, and whether they are still alive (G5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import socket
|
|
8
|
+
import sys
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _boot_id() -> str:
|
|
14
|
+
# Linux names each boot; elsewhere a pid alone has to do
|
|
15
|
+
try:
|
|
16
|
+
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
17
|
+
except OSError:
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class Owner:
|
|
23
|
+
"""A process: its pid, host, boot and start time, so a pid reused by
|
|
24
|
+
another process, after a reboot or on another machine is not mistaken
|
|
25
|
+
for it. ``boot`` and ``started`` are empty where the platform cannot
|
|
26
|
+
tell (macOS); the pid alone decides there."""
|
|
27
|
+
|
|
28
|
+
pid: int
|
|
29
|
+
host: str
|
|
30
|
+
boot: str
|
|
31
|
+
started: str = ""
|
|
32
|
+
|
|
33
|
+
@classmethod
|
|
34
|
+
def current(cls) -> Owner:
|
|
35
|
+
pid = os.getpid()
|
|
36
|
+
return cls(pid, socket.gethostname(), _BOOT, _process_start(pid))
|
|
37
|
+
|
|
38
|
+
def to_bytes(self) -> bytes:
|
|
39
|
+
return json.dumps(
|
|
40
|
+
{
|
|
41
|
+
"pid": self.pid,
|
|
42
|
+
"host": self.host,
|
|
43
|
+
"boot": self.boot,
|
|
44
|
+
"started": self.started,
|
|
45
|
+
}
|
|
46
|
+
).encode()
|
|
47
|
+
|
|
48
|
+
@classmethod
|
|
49
|
+
def from_bytes(cls, data: bytes) -> Owner | None:
|
|
50
|
+
try:
|
|
51
|
+
raw = json.loads(data)
|
|
52
|
+
return cls(
|
|
53
|
+
int(raw["pid"]),
|
|
54
|
+
str(raw["host"]),
|
|
55
|
+
str(raw["boot"]),
|
|
56
|
+
# holder files written by 0.2.0 have no start time
|
|
57
|
+
str(raw.get("started", "")),
|
|
58
|
+
)
|
|
59
|
+
except (ValueError, KeyError, TypeError, AttributeError):
|
|
60
|
+
return None
|
|
61
|
+
|
|
62
|
+
def is_alive(self) -> bool:
|
|
63
|
+
"""False only when this process is known to be gone.
|
|
64
|
+
|
|
65
|
+
An owner on another host cannot be checked from here, so it counts
|
|
66
|
+
as alive: a run is never collected on a guess.
|
|
67
|
+
"""
|
|
68
|
+
here = Owner.current()
|
|
69
|
+
if self.host != here.host:
|
|
70
|
+
return True
|
|
71
|
+
if self.boot and here.boot and self.boot != here.boot:
|
|
72
|
+
return False
|
|
73
|
+
if not _pid_alive(self.pid):
|
|
74
|
+
return False
|
|
75
|
+
if self.started:
|
|
76
|
+
# a process with this pid exists; is it the one that wrote this?
|
|
77
|
+
now = _process_start(self.pid)
|
|
78
|
+
if now and now != self.started:
|
|
79
|
+
return False
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
_BOOT = _boot_id()
|
|
84
|
+
|
|
85
|
+
if sys.platform == "win32":
|
|
86
|
+
import ctypes
|
|
87
|
+
|
|
88
|
+
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
89
|
+
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
90
|
+
_STILL_ACTIVE = 259
|
|
91
|
+
_ERROR_ACCESS_DENIED = 5
|
|
92
|
+
|
|
93
|
+
class _FileTime(ctypes.Structure):
|
|
94
|
+
_fields_ = (("low", ctypes.c_ulong), ("high", ctypes.c_ulong))
|
|
95
|
+
|
|
96
|
+
def _process_start(pid: int) -> str:
|
|
97
|
+
handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
98
|
+
if not handle:
|
|
99
|
+
return ""
|
|
100
|
+
try:
|
|
101
|
+
times = [_FileTime() for _ in range(4)]
|
|
102
|
+
if not _kernel32.GetProcessTimes(
|
|
103
|
+
handle, *(ctypes.byref(time) for time in times)
|
|
104
|
+
):
|
|
105
|
+
return ""
|
|
106
|
+
return str(times[0].high << 32 | times[0].low)
|
|
107
|
+
finally:
|
|
108
|
+
_kernel32.CloseHandle(handle)
|
|
109
|
+
|
|
110
|
+
def _pid_alive(pid: int) -> bool:
|
|
111
|
+
handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
112
|
+
if not handle:
|
|
113
|
+
return bool(ctypes.get_last_error() == _ERROR_ACCESS_DENIED)
|
|
114
|
+
try:
|
|
115
|
+
code = ctypes.c_ulong()
|
|
116
|
+
if not _kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
117
|
+
return True
|
|
118
|
+
return code.value == _STILL_ACTIVE
|
|
119
|
+
finally:
|
|
120
|
+
_kernel32.CloseHandle(handle)
|
|
121
|
+
|
|
122
|
+
else:
|
|
123
|
+
|
|
124
|
+
def _process_start(pid: int) -> str:
|
|
125
|
+
# Linux: field 22 of /proc/{pid}/stat, in clock ticks since boot;
|
|
126
|
+
# the name before it can hold spaces and parentheses
|
|
127
|
+
try:
|
|
128
|
+
stat = Path(f"/proc/{pid}/stat").read_text()
|
|
129
|
+
except OSError:
|
|
130
|
+
return ""
|
|
131
|
+
fields = stat.rpartition(")")[2].split()
|
|
132
|
+
return fields[19] if len(fields) > 19 else ""
|
|
133
|
+
|
|
134
|
+
def _pid_alive(pid: int) -> bool:
|
|
135
|
+
try:
|
|
136
|
+
os.kill(pid, 0)
|
|
137
|
+
except ProcessLookupError:
|
|
138
|
+
return False
|
|
139
|
+
except PermissionError:
|
|
140
|
+
return True
|
|
141
|
+
return True
|
|
@@ -9,10 +9,15 @@ Loaded with ``-p``, it unregisters pytest's ``tmpdir`` plugin and provides
|
|
|
9
9
|
``tmp_path`` and ``tmp_path_factory`` itself. The ``py.path`` fixtures
|
|
10
10
|
``tmpdir`` and ``tmpdir_factory`` are left out on purpose: with the plugin
|
|
11
11
|
on, a test that asks for them fails with "fixture not found".
|
|
12
|
-
pytest's
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
136
|
-
|
|
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
|
-
|
|
142
|
-
except
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
196
|
-
# as it is for pytest
|
|
197
|
-
|
|
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
|
|
201
|
-
#
|
|
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
|
-
|
|
194
|
+
factory: TempPathFactory = request.config._tmp_path_factory # type: ignore[attr-defined]
|
|
195
|
+
return factory
|
|
208
196
|
|
|
209
197
|
|
|
210
198
|
@pytest.fixture
|
|
@@ -11,6 +11,7 @@ import pytest
|
|
|
11
11
|
|
|
12
12
|
from conftest import run_python
|
|
13
13
|
from cot.tmppath import Retention, Root
|
|
14
|
+
from cot.tmppath._owner import Owner
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
def test_another_process_can_join_a_run(root_path: Path) -> None:
|
|
@@ -87,3 +88,17 @@ def test_place_is_atomic(root_path: Path) -> None:
|
|
|
87
88
|
staging.write_bytes(b"whole")
|
|
88
89
|
assert destination.read_bytes() == b"whole"
|
|
89
90
|
assert list(destination.parent.iterdir()) == [destination]
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@pytest.mark.skipif(
|
|
94
|
+
not Owner.current().started, reason="no process start time on this platform"
|
|
95
|
+
)
|
|
96
|
+
def test_reused_pid_does_not_keep_a_run_alive(root_path: Path) -> None:
|
|
97
|
+
run = Root(root_path).start_run()
|
|
98
|
+
# what a crashed holder looks like once its pid went to another process
|
|
99
|
+
here = Owner.current()
|
|
100
|
+
reused = Owner(here.pid, here.host, here.boot, started="0")
|
|
101
|
+
(holder,) = (run.path / ".cot-holders").iterdir()
|
|
102
|
+
holder.write_bytes(reused.to_bytes())
|
|
103
|
+
Root(root_path, retention=Retention(keep_runs=0)).prune()
|
|
104
|
+
assert not run.path.exists()
|
|
@@ -7,9 +7,7 @@ behaviour tests are xfail until the core is built.
|
|
|
7
7
|
|
|
8
8
|
from __future__ import annotations
|
|
9
9
|
|
|
10
|
-
import os
|
|
11
10
|
import sys
|
|
12
|
-
import time
|
|
13
11
|
|
|
14
12
|
import pytest
|
|
15
13
|
|
|
@@ -31,7 +29,7 @@ def test_addopts_opt_in_takes_over(pytester: pytest.Pytester) -> None:
|
|
|
31
29
|
def test_takeover(request, tmp_path_factory):
|
|
32
30
|
config = request.config
|
|
33
31
|
assert not config.pluginmanager.has_plugin("tmpdir")
|
|
34
|
-
assert
|
|
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=*
|
|
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
|
|
153
|
+
def test_basetemp_holds_the_runs_and_is_reused(pytester: pytest.Pytester) -> None:
|
|
155
154
|
base = pytester.path / "new"
|
|
156
|
-
pytester.makeini(
|
|
157
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
pytester.makepyfile("def
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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 = []
|
cot_tmppath-0.2.0/CHANGELOG.md
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
<!-- towncrier release notes start -->
|
|
4
|
-
|
|
5
|
-
## 0.2.0 (2026-10-07)
|
|
6
|
-
|
|
7
|
-
### Removed
|
|
8
|
-
|
|
9
|
-
- The pytest plugin no longer provides the `py.path` fixtures `tmpdir` and `tmpdir_factory`; use `tmp_path` and `tmp_path_factory`.
|
|
10
|
-
|
|
11
|
-
### Added
|
|
12
|
-
|
|
13
|
-
- Build the core: `Root`, `Run`, the flat and pytest layouts, retention, pruning, liveness and atomic placement now work instead of raising `NotImplementedError`, so `-p cot.tmppath.overtake_pytest` provides working `tmp_path` and `tmp_path_factory` fixtures.
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
"""Who holds a run, and whether they are still alive (G5)."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import os
|
|
7
|
-
import socket
|
|
8
|
-
import sys
|
|
9
|
-
from dataclasses import dataclass
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def _boot_id() -> str:
|
|
14
|
-
# Linux names each boot; elsewhere a pid alone has to do
|
|
15
|
-
try:
|
|
16
|
-
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
17
|
-
except OSError:
|
|
18
|
-
return ""
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass(frozen=True)
|
|
22
|
-
class Owner:
|
|
23
|
-
"""A process: its pid, host and boot, so a reused pid after a reboot
|
|
24
|
-
or on another machine is not mistaken for it."""
|
|
25
|
-
|
|
26
|
-
pid: int
|
|
27
|
-
host: str
|
|
28
|
-
boot: str
|
|
29
|
-
|
|
30
|
-
@classmethod
|
|
31
|
-
def current(cls) -> Owner:
|
|
32
|
-
return cls(os.getpid(), socket.gethostname(), _BOOT)
|
|
33
|
-
|
|
34
|
-
def to_bytes(self) -> bytes:
|
|
35
|
-
return json.dumps(
|
|
36
|
-
{"pid": self.pid, "host": self.host, "boot": self.boot}
|
|
37
|
-
).encode()
|
|
38
|
-
|
|
39
|
-
@classmethod
|
|
40
|
-
def from_bytes(cls, data: bytes) -> Owner | None:
|
|
41
|
-
try:
|
|
42
|
-
raw = json.loads(data)
|
|
43
|
-
return cls(int(raw["pid"]), str(raw["host"]), str(raw["boot"]))
|
|
44
|
-
except (ValueError, KeyError, TypeError):
|
|
45
|
-
return None
|
|
46
|
-
|
|
47
|
-
def is_alive(self) -> bool:
|
|
48
|
-
"""False only when this process is known to be gone.
|
|
49
|
-
|
|
50
|
-
An owner on another host cannot be checked from here, so it counts
|
|
51
|
-
as alive: a run is never collected on a guess.
|
|
52
|
-
"""
|
|
53
|
-
here = Owner.current()
|
|
54
|
-
if self.host != here.host:
|
|
55
|
-
return True
|
|
56
|
-
if self.boot and here.boot and self.boot != here.boot:
|
|
57
|
-
return False
|
|
58
|
-
return _pid_alive(self.pid)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
_BOOT = _boot_id()
|
|
62
|
-
|
|
63
|
-
if sys.platform == "win32":
|
|
64
|
-
import ctypes
|
|
65
|
-
|
|
66
|
-
_kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
|
|
67
|
-
_PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
|
68
|
-
_STILL_ACTIVE = 259
|
|
69
|
-
_ERROR_ACCESS_DENIED = 5
|
|
70
|
-
|
|
71
|
-
def _pid_alive(pid: int) -> bool:
|
|
72
|
-
handle = _kernel32.OpenProcess(_PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
|
|
73
|
-
if not handle:
|
|
74
|
-
return bool(ctypes.get_last_error() == _ERROR_ACCESS_DENIED)
|
|
75
|
-
try:
|
|
76
|
-
code = ctypes.c_ulong()
|
|
77
|
-
if not _kernel32.GetExitCodeProcess(handle, ctypes.byref(code)):
|
|
78
|
-
return True
|
|
79
|
-
return code.value == _STILL_ACTIVE
|
|
80
|
-
finally:
|
|
81
|
-
_kernel32.CloseHandle(handle)
|
|
82
|
-
|
|
83
|
-
else:
|
|
84
|
-
|
|
85
|
-
def _pid_alive(pid: int) -> bool:
|
|
86
|
-
try:
|
|
87
|
-
os.kill(pid, 0)
|
|
88
|
-
except ProcessLookupError:
|
|
89
|
-
return False
|
|
90
|
-
except PermissionError:
|
|
91
|
-
return True
|
|
92
|
-
return True
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|