restore-verified 0.0.1__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.
- restore_verified-0.0.1/LICENSE +21 -0
- restore_verified-0.0.1/PKG-INFO +214 -0
- restore_verified-0.0.1/README.md +198 -0
- restore_verified-0.0.1/pyproject.toml +30 -0
- restore_verified-0.0.1/restore_verified/__init__.py +60 -0
- restore_verified-0.0.1/restore_verified/cli.py +177 -0
- restore_verified-0.0.1/restore_verified/guard.py +319 -0
- restore_verified-0.0.1/restore_verified/sentinel.py +274 -0
- restore_verified-0.0.1/restore_verified.egg-info/PKG-INFO +214 -0
- restore_verified-0.0.1/restore_verified.egg-info/SOURCES.txt +13 -0
- restore_verified-0.0.1/restore_verified.egg-info/dependency_links.txt +1 -0
- restore_verified-0.0.1/restore_verified.egg-info/entry_points.txt +2 -0
- restore_verified-0.0.1/restore_verified.egg-info/top_level.txt +1 -0
- restore_verified-0.0.1/setup.cfg +4 -0
- restore_verified-0.0.1/tests/test_guard.py +338 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Seth Wheeler
|
|
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,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: restore-verified
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Temporarily modify a file, survive the signal, and prove the tree came back.
|
|
5
|
+
License: MIT
|
|
6
|
+
Keywords: restore,rollback,in-place,in-place-edit,signal,sigterm,sigkill,timeout,mutation-testing,codemod,crash-safe,verify,checksum,filesystem,cleanup,harness
|
|
7
|
+
Classifier: Development Status :: 3 - Alpha
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: Topic :: Software Development :: Testing
|
|
12
|
+
Requires-Python: >=3.9
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
License-File: LICENSE
|
|
15
|
+
Dynamic: license-file
|
|
16
|
+
|
|
17
|
+
# `restore-verified`
|
|
18
|
+
|
|
19
|
+
Temporarily modify a file, survive the signal, and **prove the tree came back**.
|
|
20
|
+
|
|
21
|
+
For anything that breaks a file on purpose and puts it back: a mutation harness, a
|
|
22
|
+
codemod, a benchmark that swaps a config, a test that patches a fixture.
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
from restore_verified import guarded
|
|
26
|
+
|
|
27
|
+
with guarded("src/parser.py") as g:
|
|
28
|
+
g.write(g.read().replace("<=", "<"))
|
|
29
|
+
run_the_suite()
|
|
30
|
+
# restored here — and the restore is checked, byte for byte
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
# the case nothing else covers: the tool is SIGKILLed by a timeout mid-edit
|
|
35
|
+
restore-verified run --paths src/ --timeout 600 --restore -- ./harness.sh
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Read this first: on a clean git checkout, use git
|
|
39
|
+
|
|
40
|
+
`git diff --quiet` already catches an unrestored change and `git checkout -- FILE`
|
|
41
|
+
already fixes it. That is free, it is correct, and it is what you should do. This
|
|
42
|
+
package is for the cases where it is not true — and those are not exotic:
|
|
43
|
+
|
|
44
|
+
| | clean tree | **dirty tree** (a developer's checkout) |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| `git diff --quiet` before | clean | DIRTY |
|
|
47
|
+
| `git diff --quiet` after a failed restore | DIRTY — **caught** | DIRTY — **indistinguishable** |
|
|
48
|
+
| `git checkout -- FILE` | restores | **destroys the uncommitted work** |
|
|
49
|
+
|
|
50
|
+
Both rows are asserted in `tests/test_guard.py::TheGitControl`, including the one that
|
|
51
|
+
says *if git preserved the uncommitted work, use git*.
|
|
52
|
+
|
|
53
|
+
The reason is structural, not incidental: **a snapshot here is per-file and taken when
|
|
54
|
+
you start; git's is repo-wide and taken at the last commit.** Those are the same thing
|
|
55
|
+
only on a clean tree. The other cases git cannot serve at all are untracked or ignored
|
|
56
|
+
files — generated code, fetched fixtures, local config — and not being in a repository:
|
|
57
|
+
a container, an installed package, an unpacked tarball.
|
|
58
|
+
|
|
59
|
+
## The four failures, and which layer covers each
|
|
60
|
+
|
|
61
|
+
| | what covers it | what happens without it |
|
|
62
|
+
|---|---|---|
|
|
63
|
+
| an **exception** mid-run | `try/finally` — and every in-place-edit package on either registry | the file stays broken |
|
|
64
|
+
| a **signal** | this package's `Guard` | **`finally` does not run on SIGTERM.** No handler, no unwinding; the file stays broken |
|
|
65
|
+
| the **restore itself being wrong** | this package's verification | a restore that *ran* is not a restore that *worked* |
|
|
66
|
+
| **SIGKILL / a timeout** | this package's `Sentinel`, one process outward | nothing in-process can help; the file stays broken |
|
|
67
|
+
|
|
68
|
+
The second row is measured, not asserted. `tests/child.py` runs the same mutation three
|
|
69
|
+
ways in a real subprocess, and the test suite kills it for real:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
test_SIGTERM_leaves_a_try_finally_harness_broken ......... ok
|
|
73
|
+
test_SIGTERM_does_not_leave_a_guarded_harness_broken ..... ok
|
|
74
|
+
test_the_signal_is_re_delivered_so_a_kill_still_kills .... ok
|
|
75
|
+
test_SIGKILL_defeats_the_guard_and_the_sentinel_catches_it ok
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The first of those is the control. **If `try/finally` ever survives SIGTERM, the premise
|
|
79
|
+
of this package is wrong and the test says so in those words.**
|
|
80
|
+
|
|
81
|
+
### The third row is the name
|
|
82
|
+
|
|
83
|
+
`in-place` (PyPI) restores the original *if an exception occurs*. `fs-transaction`
|
|
84
|
+
(PyPI) rolls back a failed *write*. A second sweep — PyPI's full 881,198-name index for
|
|
85
|
+
`restore`, `rollback`, `revert`, `atomic`, `sigterm` and `in-place`, plus web search for
|
|
86
|
+
the combination — turned up nothing further. `atomically` and `write-file-atomic` (npm, 16M and
|
|
87
|
+
more downloads a week) make a write all-or-nothing. **None of them re-reads what it put
|
|
88
|
+
back.** A restore can run perfectly and still be wrong: a buffer captured *after*
|
|
89
|
+
mutating, a different encoding on the way out, one of the two files you touched. All
|
|
90
|
+
three leave the restore path looking healthy, and every run after them scores code
|
|
91
|
+
nobody wrote. Hashing before and comparing after is the only check that separates
|
|
92
|
+
*ran* from *worked*.
|
|
93
|
+
|
|
94
|
+
### The fourth row is the one with no incumbent anywhere
|
|
95
|
+
|
|
96
|
+
SIGKILL cannot be caught, blocked or handled. The ordinary way to be SIGKILLed is not
|
|
97
|
+
an impatient person — it is a **timeout**. `subprocess.run(..., timeout=...)` calls
|
|
98
|
+
`Popen.kill()` when the deadline passes, and so does the kill step of a CI runner that
|
|
99
|
+
has waited long enough. A harness carrying a *perfect* in-process guard, invoked under a
|
|
100
|
+
timeout it exceeds, leaves the tree exactly as broken as one carrying no guard at all:
|
|
101
|
+
|
|
102
|
+
```
|
|
103
|
+
$ restore-verified run --paths /tmp/demo/m.py --timeout 2 -- python3 harness.py
|
|
104
|
+
[restore-verified] the command exceeded 2.0s and was SIGKILLed — no handler, no `finally`, no cleanup ran
|
|
105
|
+
THE TREE DID NOT COME BACK — 1 file(s):
|
|
106
|
+
changed /tmp/demo/m.py — 27 bytes -> 8 bytes, digest 64545701caa5 -> 14309db042d4
|
|
107
|
+
|
|
108
|
+
Everything measured after this point scores code nobody wrote.
|
|
109
|
+
Re-run with --restore to put them back from the snapshot.
|
|
110
|
+
$ echo $?
|
|
111
|
+
3
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
That harness had a flawless guard. The check has to live in whatever invoked it.
|
|
115
|
+
|
|
116
|
+
## Design decisions worth knowing
|
|
117
|
+
|
|
118
|
+
**A caught signal is re-delivered.** Swallowing SIGTERM turns `kill` into "nothing
|
|
119
|
+
happened", which is a worse bug than the one being fixed. The guard restores the file,
|
|
120
|
+
puts the handler back to the default, and re-raises the signal at itself — so the
|
|
121
|
+
process dies with status `-15`, as the sender intended. Asserted.
|
|
122
|
+
|
|
123
|
+
**`Interrupted` inherits from `BaseException`.** A bare `except Exception:` inside the
|
|
124
|
+
guarded body — ordinary defensive code — would otherwise swallow the interruption and
|
|
125
|
+
keep running against a mutated tree after someone asked it to stop.
|
|
126
|
+
|
|
127
|
+
**Nothing is written beside the code under test.** The snapshot lives in a temp
|
|
128
|
+
directory, not in `foo.py.bak`. A scratch file in the directory being measured changes
|
|
129
|
+
what a file walker collects, what a test runner discovers, and what a coverage
|
|
130
|
+
denominator counts. A clean target is not a clean tree.
|
|
131
|
+
|
|
132
|
+
**`g.read()` returns the original, from the snapshot.** Reading the file back after
|
|
133
|
+
mutating it and calling that "the original" is one of the three ways a restore runs and
|
|
134
|
+
does not work; taking it from the snapshot makes the mistake unavailable.
|
|
135
|
+
|
|
136
|
+
**mtime is restored too** (pass `restore_mtime=False` to opt out). A build system, a
|
|
137
|
+
test cache and a file watcher all key on mtime, and a guard that triggers a full rebuild
|
|
138
|
+
on every run is a guard people switch off.
|
|
139
|
+
|
|
140
|
+
> **The tension, found by building [`canfail`](../canfail) on top of this.** If your tool
|
|
141
|
+
> *compiles or imports* the file it just restored, restoring mtime is wrong: a bytecode
|
|
142
|
+
> cache written from the broken source then looks fresh. Worse, `restore_mtime=False` is
|
|
143
|
+
> **not sufficient** either — mtime invalidation has one-second granularity, and an
|
|
144
|
+
> edit/run/restore cycle in milliseconds defeats it whichever way you set this. Disable
|
|
145
|
+
> the cache (`PYTHONDONTWRITEBYTECODE=1`, `make -B`) rather than relying on the clock.
|
|
146
|
+
|
|
147
|
+
**Off the main thread it says so.** `signal.signal` only works on the main thread, so
|
|
148
|
+
there the guard degrades to a `try/finally` — and sets `g.signal_note` to explain it,
|
|
149
|
+
rather than covering half the job silently.
|
|
150
|
+
|
|
151
|
+
**Three drift outcomes, not two.** `changed`, `missing` and `created` are kept apart: a
|
|
152
|
+
missing file and a changed one send you to opposite ends of the problem.
|
|
153
|
+
|
|
154
|
+
**A digest-only manifest refuses to pretend it can restore.** `Sentinel.record` on a
|
|
155
|
+
directory keeps digests and no content by default, so `verify()` works and `restore()`
|
|
156
|
+
raises rather than silently doing nothing.
|
|
157
|
+
|
|
158
|
+
## API
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from restore_verified import guarded, Sentinel, RestoreFailed
|
|
162
|
+
|
|
163
|
+
with guarded("a.py", "b.py") as g: # one file or many
|
|
164
|
+
g.write("a.py", mutated_text)
|
|
165
|
+
...
|
|
166
|
+
# RestoreFailed if anything did not come back byte for byte
|
|
167
|
+
|
|
168
|
+
sentinel = Sentinel.record(["src/"]) # digests; add keep_content=True to restore
|
|
169
|
+
manifest = sentinel.save() # survives the process that broke the tree
|
|
170
|
+
...
|
|
171
|
+
for drift in Sentinel.load(manifest).verify():
|
|
172
|
+
print(drift)
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
```sh
|
|
176
|
+
restore-verified run --paths src/ [--timeout N] [--restore] -- CMD...
|
|
177
|
+
restore-verified record --paths src/ --manifest before.json
|
|
178
|
+
restore-verified verify --manifest before.json [--restore]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Exit code **3** means the tree did not come back — its own code, never folded into the
|
|
182
|
+
command's status, because a harness that exits 0 having left a file mutated is the exact
|
|
183
|
+
failure this exists to report.
|
|
184
|
+
|
|
185
|
+
`--restore` still exits 3. That this command could put the tree back makes the run
|
|
186
|
+
recoverable, not trustworthy.
|
|
187
|
+
|
|
188
|
+
## Scope, honestly
|
|
189
|
+
|
|
190
|
+
- **Mature mutation frameworks do not need this.** mutmut 3 copies `source_paths` to a
|
|
191
|
+
`mutants/` directory and mutates the copy; StrykerJS sandboxes likewise. Avoiding
|
|
192
|
+
in-place mutation is a better answer than guarding it, and if you can restructure that
|
|
193
|
+
way, do. This is for the tools that cannot — hand-rolled harnesses, codemods that must
|
|
194
|
+
run against the real tree, anything whose build config points at the original path.
|
|
195
|
+
- Zero dependencies, standard library only, Python 3.9+.
|
|
196
|
+
- POSIX signals. On Windows there is no SIGTERM in the POSIX sense; the guard covers
|
|
197
|
+
exceptions and `Sentinel` covers the rest.
|
|
198
|
+
- It does not lock. Two processes guarding the same file will not see each other.
|
|
199
|
+
- `Sentinel.record` on a large tree costs one SHA-256 read per file.
|
|
200
|
+
|
|
201
|
+
## Tests
|
|
202
|
+
|
|
203
|
+
```sh
|
|
204
|
+
python3 -m unittest discover -s tests
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
25 tests, no dependencies. The signal tests spawn a real child and really kill it,
|
|
208
|
+
because the question is not "does the handler run" but "what does the file on disk look
|
|
209
|
+
like after somebody types `kill`".
|
|
210
|
+
|
|
211
|
+
Five mutations to the source were applied — with this package's own guard — and all five
|
|
212
|
+
were caught by the test that should catch them: removing the signal installation,
|
|
213
|
+
removing the verification, dropping the re-delivery, keeping the snapshot beside the
|
|
214
|
+
code, and making `verify` always report clean.
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
# `restore-verified`
|
|
2
|
+
|
|
3
|
+
Temporarily modify a file, survive the signal, and **prove the tree came back**.
|
|
4
|
+
|
|
5
|
+
For anything that breaks a file on purpose and puts it back: a mutation harness, a
|
|
6
|
+
codemod, a benchmark that swaps a config, a test that patches a fixture.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from restore_verified import guarded
|
|
10
|
+
|
|
11
|
+
with guarded("src/parser.py") as g:
|
|
12
|
+
g.write(g.read().replace("<=", "<"))
|
|
13
|
+
run_the_suite()
|
|
14
|
+
# restored here — and the restore is checked, byte for byte
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
# the case nothing else covers: the tool is SIGKILLed by a timeout mid-edit
|
|
19
|
+
restore-verified run --paths src/ --timeout 600 --restore -- ./harness.sh
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Read this first: on a clean git checkout, use git
|
|
23
|
+
|
|
24
|
+
`git diff --quiet` already catches an unrestored change and `git checkout -- FILE`
|
|
25
|
+
already fixes it. That is free, it is correct, and it is what you should do. This
|
|
26
|
+
package is for the cases where it is not true — and those are not exotic:
|
|
27
|
+
|
|
28
|
+
| | clean tree | **dirty tree** (a developer's checkout) |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `git diff --quiet` before | clean | DIRTY |
|
|
31
|
+
| `git diff --quiet` after a failed restore | DIRTY — **caught** | DIRTY — **indistinguishable** |
|
|
32
|
+
| `git checkout -- FILE` | restores | **destroys the uncommitted work** |
|
|
33
|
+
|
|
34
|
+
Both rows are asserted in `tests/test_guard.py::TheGitControl`, including the one that
|
|
35
|
+
says *if git preserved the uncommitted work, use git*.
|
|
36
|
+
|
|
37
|
+
The reason is structural, not incidental: **a snapshot here is per-file and taken when
|
|
38
|
+
you start; git's is repo-wide and taken at the last commit.** Those are the same thing
|
|
39
|
+
only on a clean tree. The other cases git cannot serve at all are untracked or ignored
|
|
40
|
+
files — generated code, fetched fixtures, local config — and not being in a repository:
|
|
41
|
+
a container, an installed package, an unpacked tarball.
|
|
42
|
+
|
|
43
|
+
## The four failures, and which layer covers each
|
|
44
|
+
|
|
45
|
+
| | what covers it | what happens without it |
|
|
46
|
+
|---|---|---|
|
|
47
|
+
| an **exception** mid-run | `try/finally` — and every in-place-edit package on either registry | the file stays broken |
|
|
48
|
+
| a **signal** | this package's `Guard` | **`finally` does not run on SIGTERM.** No handler, no unwinding; the file stays broken |
|
|
49
|
+
| the **restore itself being wrong** | this package's verification | a restore that *ran* is not a restore that *worked* |
|
|
50
|
+
| **SIGKILL / a timeout** | this package's `Sentinel`, one process outward | nothing in-process can help; the file stays broken |
|
|
51
|
+
|
|
52
|
+
The second row is measured, not asserted. `tests/child.py` runs the same mutation three
|
|
53
|
+
ways in a real subprocess, and the test suite kills it for real:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
test_SIGTERM_leaves_a_try_finally_harness_broken ......... ok
|
|
57
|
+
test_SIGTERM_does_not_leave_a_guarded_harness_broken ..... ok
|
|
58
|
+
test_the_signal_is_re_delivered_so_a_kill_still_kills .... ok
|
|
59
|
+
test_SIGKILL_defeats_the_guard_and_the_sentinel_catches_it ok
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
The first of those is the control. **If `try/finally` ever survives SIGTERM, the premise
|
|
63
|
+
of this package is wrong and the test says so in those words.**
|
|
64
|
+
|
|
65
|
+
### The third row is the name
|
|
66
|
+
|
|
67
|
+
`in-place` (PyPI) restores the original *if an exception occurs*. `fs-transaction`
|
|
68
|
+
(PyPI) rolls back a failed *write*. A second sweep — PyPI's full 881,198-name index for
|
|
69
|
+
`restore`, `rollback`, `revert`, `atomic`, `sigterm` and `in-place`, plus web search for
|
|
70
|
+
the combination — turned up nothing further. `atomically` and `write-file-atomic` (npm, 16M and
|
|
71
|
+
more downloads a week) make a write all-or-nothing. **None of them re-reads what it put
|
|
72
|
+
back.** A restore can run perfectly and still be wrong: a buffer captured *after*
|
|
73
|
+
mutating, a different encoding on the way out, one of the two files you touched. All
|
|
74
|
+
three leave the restore path looking healthy, and every run after them scores code
|
|
75
|
+
nobody wrote. Hashing before and comparing after is the only check that separates
|
|
76
|
+
*ran* from *worked*.
|
|
77
|
+
|
|
78
|
+
### The fourth row is the one with no incumbent anywhere
|
|
79
|
+
|
|
80
|
+
SIGKILL cannot be caught, blocked or handled. The ordinary way to be SIGKILLed is not
|
|
81
|
+
an impatient person — it is a **timeout**. `subprocess.run(..., timeout=...)` calls
|
|
82
|
+
`Popen.kill()` when the deadline passes, and so does the kill step of a CI runner that
|
|
83
|
+
has waited long enough. A harness carrying a *perfect* in-process guard, invoked under a
|
|
84
|
+
timeout it exceeds, leaves the tree exactly as broken as one carrying no guard at all:
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
$ restore-verified run --paths /tmp/demo/m.py --timeout 2 -- python3 harness.py
|
|
88
|
+
[restore-verified] the command exceeded 2.0s and was SIGKILLed — no handler, no `finally`, no cleanup ran
|
|
89
|
+
THE TREE DID NOT COME BACK — 1 file(s):
|
|
90
|
+
changed /tmp/demo/m.py — 27 bytes -> 8 bytes, digest 64545701caa5 -> 14309db042d4
|
|
91
|
+
|
|
92
|
+
Everything measured after this point scores code nobody wrote.
|
|
93
|
+
Re-run with --restore to put them back from the snapshot.
|
|
94
|
+
$ echo $?
|
|
95
|
+
3
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
That harness had a flawless guard. The check has to live in whatever invoked it.
|
|
99
|
+
|
|
100
|
+
## Design decisions worth knowing
|
|
101
|
+
|
|
102
|
+
**A caught signal is re-delivered.** Swallowing SIGTERM turns `kill` into "nothing
|
|
103
|
+
happened", which is a worse bug than the one being fixed. The guard restores the file,
|
|
104
|
+
puts the handler back to the default, and re-raises the signal at itself — so the
|
|
105
|
+
process dies with status `-15`, as the sender intended. Asserted.
|
|
106
|
+
|
|
107
|
+
**`Interrupted` inherits from `BaseException`.** A bare `except Exception:` inside the
|
|
108
|
+
guarded body — ordinary defensive code — would otherwise swallow the interruption and
|
|
109
|
+
keep running against a mutated tree after someone asked it to stop.
|
|
110
|
+
|
|
111
|
+
**Nothing is written beside the code under test.** The snapshot lives in a temp
|
|
112
|
+
directory, not in `foo.py.bak`. A scratch file in the directory being measured changes
|
|
113
|
+
what a file walker collects, what a test runner discovers, and what a coverage
|
|
114
|
+
denominator counts. A clean target is not a clean tree.
|
|
115
|
+
|
|
116
|
+
**`g.read()` returns the original, from the snapshot.** Reading the file back after
|
|
117
|
+
mutating it and calling that "the original" is one of the three ways a restore runs and
|
|
118
|
+
does not work; taking it from the snapshot makes the mistake unavailable.
|
|
119
|
+
|
|
120
|
+
**mtime is restored too** (pass `restore_mtime=False` to opt out). A build system, a
|
|
121
|
+
test cache and a file watcher all key on mtime, and a guard that triggers a full rebuild
|
|
122
|
+
on every run is a guard people switch off.
|
|
123
|
+
|
|
124
|
+
> **The tension, found by building [`canfail`](../canfail) on top of this.** If your tool
|
|
125
|
+
> *compiles or imports* the file it just restored, restoring mtime is wrong: a bytecode
|
|
126
|
+
> cache written from the broken source then looks fresh. Worse, `restore_mtime=False` is
|
|
127
|
+
> **not sufficient** either — mtime invalidation has one-second granularity, and an
|
|
128
|
+
> edit/run/restore cycle in milliseconds defeats it whichever way you set this. Disable
|
|
129
|
+
> the cache (`PYTHONDONTWRITEBYTECODE=1`, `make -B`) rather than relying on the clock.
|
|
130
|
+
|
|
131
|
+
**Off the main thread it says so.** `signal.signal` only works on the main thread, so
|
|
132
|
+
there the guard degrades to a `try/finally` — and sets `g.signal_note` to explain it,
|
|
133
|
+
rather than covering half the job silently.
|
|
134
|
+
|
|
135
|
+
**Three drift outcomes, not two.** `changed`, `missing` and `created` are kept apart: a
|
|
136
|
+
missing file and a changed one send you to opposite ends of the problem.
|
|
137
|
+
|
|
138
|
+
**A digest-only manifest refuses to pretend it can restore.** `Sentinel.record` on a
|
|
139
|
+
directory keeps digests and no content by default, so `verify()` works and `restore()`
|
|
140
|
+
raises rather than silently doing nothing.
|
|
141
|
+
|
|
142
|
+
## API
|
|
143
|
+
|
|
144
|
+
```python
|
|
145
|
+
from restore_verified import guarded, Sentinel, RestoreFailed
|
|
146
|
+
|
|
147
|
+
with guarded("a.py", "b.py") as g: # one file or many
|
|
148
|
+
g.write("a.py", mutated_text)
|
|
149
|
+
...
|
|
150
|
+
# RestoreFailed if anything did not come back byte for byte
|
|
151
|
+
|
|
152
|
+
sentinel = Sentinel.record(["src/"]) # digests; add keep_content=True to restore
|
|
153
|
+
manifest = sentinel.save() # survives the process that broke the tree
|
|
154
|
+
...
|
|
155
|
+
for drift in Sentinel.load(manifest).verify():
|
|
156
|
+
print(drift)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
```sh
|
|
160
|
+
restore-verified run --paths src/ [--timeout N] [--restore] -- CMD...
|
|
161
|
+
restore-verified record --paths src/ --manifest before.json
|
|
162
|
+
restore-verified verify --manifest before.json [--restore]
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Exit code **3** means the tree did not come back — its own code, never folded into the
|
|
166
|
+
command's status, because a harness that exits 0 having left a file mutated is the exact
|
|
167
|
+
failure this exists to report.
|
|
168
|
+
|
|
169
|
+
`--restore` still exits 3. That this command could put the tree back makes the run
|
|
170
|
+
recoverable, not trustworthy.
|
|
171
|
+
|
|
172
|
+
## Scope, honestly
|
|
173
|
+
|
|
174
|
+
- **Mature mutation frameworks do not need this.** mutmut 3 copies `source_paths` to a
|
|
175
|
+
`mutants/` directory and mutates the copy; StrykerJS sandboxes likewise. Avoiding
|
|
176
|
+
in-place mutation is a better answer than guarding it, and if you can restructure that
|
|
177
|
+
way, do. This is for the tools that cannot — hand-rolled harnesses, codemods that must
|
|
178
|
+
run against the real tree, anything whose build config points at the original path.
|
|
179
|
+
- Zero dependencies, standard library only, Python 3.9+.
|
|
180
|
+
- POSIX signals. On Windows there is no SIGTERM in the POSIX sense; the guard covers
|
|
181
|
+
exceptions and `Sentinel` covers the rest.
|
|
182
|
+
- It does not lock. Two processes guarding the same file will not see each other.
|
|
183
|
+
- `Sentinel.record` on a large tree costs one SHA-256 read per file.
|
|
184
|
+
|
|
185
|
+
## Tests
|
|
186
|
+
|
|
187
|
+
```sh
|
|
188
|
+
python3 -m unittest discover -s tests
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
25 tests, no dependencies. The signal tests spawn a real child and really kill it,
|
|
192
|
+
because the question is not "does the handler run" but "what does the file on disk look
|
|
193
|
+
like after somebody types `kill`".
|
|
194
|
+
|
|
195
|
+
Five mutations to the source were applied — with this package's own guard — and all five
|
|
196
|
+
were caught by the test that should catch them: removing the signal installation,
|
|
197
|
+
removing the verification, dropping the re-delivery, keeping the snapshot beside the
|
|
198
|
+
code, and making `verify` always report clean.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "restore-verified"
|
|
7
|
+
version = "0.0.1"
|
|
8
|
+
description = "Temporarily modify a file, survive the signal, and prove the tree came back."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = [
|
|
13
|
+
"restore", "rollback", "in-place", "in-place-edit", "signal", "sigterm", "sigkill",
|
|
14
|
+
"timeout", "mutation-testing", "codemod", "crash-safe", "verify", "checksum",
|
|
15
|
+
"filesystem", "cleanup", "harness",
|
|
16
|
+
]
|
|
17
|
+
classifiers = [
|
|
18
|
+
"Development Status :: 3 - Alpha",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Topic :: Software Development :: Testing",
|
|
23
|
+
]
|
|
24
|
+
dependencies = []
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
restore-verified = "restore_verified.cli:main"
|
|
28
|
+
|
|
29
|
+
[tool.setuptools]
|
|
30
|
+
packages = ["restore_verified"]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Temporarily modify a file, and PROVE it came back.
|
|
2
|
+
|
|
3
|
+
Three failures, and they are not the same failure. A tool that breaks a file on
|
|
4
|
+
purpose — a mutation harness, a codemod, a benchmark that swaps a config, a test that
|
|
5
|
+
patches a fixture — has to survive all three, and the usual answer covers only the
|
|
6
|
+
first:
|
|
7
|
+
|
|
8
|
+
1. AN EXCEPTION mid-run. `try/finally` covers this, and this is what every
|
|
9
|
+
"in-place edit" package on either registry implements.
|
|
10
|
+
|
|
11
|
+
2. A SIGNAL. `finally` does NOT run on SIGTERM: the default disposition terminates
|
|
12
|
+
the process, no handler runs, no unwinding happens, and the file stays broken.
|
|
13
|
+
`kill` and most CI cancel buttons send SIGTERM.
|
|
14
|
+
|
|
15
|
+
3. THE RESTORE ITSELF FAILING. A restore that *ran* is not a restore that *worked*.
|
|
16
|
+
Reading the buffer back after mutating, writing it in a different encoding, or
|
|
17
|
+
restoring one of the two files touched all satisfy 1 and 2 and still leave the
|
|
18
|
+
tree wrong — and every later run scores code nobody wrote. Hashing before and
|
|
19
|
+
comparing after is the only check that separates them.
|
|
20
|
+
|
|
21
|
+
There is a fourth, and it cannot be fixed from in here: SIGKILL cannot be caught,
|
|
22
|
+
blocked or handled. The ordinary way to be SIGKILLed is not an impatient person but a
|
|
23
|
+
TIMEOUT — `subprocess.run(..., timeout=...)` kills the child outright, and so does the
|
|
24
|
+
kill step of a CI runner that has waited long enough. So a guard satisfying all three
|
|
25
|
+
above, invoked under a timeout it then exceeds, leaves the tree exactly as broken as
|
|
26
|
+
one carrying none of them. That check belongs to whatever INVOKED the tool, and it is
|
|
27
|
+
`restore_verified.Sentinel` — see `sentinel.py`.
|
|
28
|
+
|
|
29
|
+
WHAT THIS IS NOT FOR. In a clean git checkout, `git diff --quiet` already catches an
|
|
30
|
+
unrestored change and `git checkout -- FILE` already fixes it, for free, and you should
|
|
31
|
+
use that. This exists for the cases where that is not true, which are not exotic:
|
|
32
|
+
|
|
33
|
+
* A DIRTY WORKING TREE — the normal state of a developer's checkout. `git diff` reads
|
|
34
|
+
DIRTY both before and after, so it cannot tell the two apart, and `git checkout --`
|
|
35
|
+
silently discards the uncommitted work it was supposed to be protecting.
|
|
36
|
+
* Untracked or ignored files: generated code, fetched fixtures, local config.
|
|
37
|
+
* No repository at all: a container, an installed package, an unpacked tarball.
|
|
38
|
+
|
|
39
|
+
A snapshot here is per-file and taken when you start. Git's is repo-wide and taken at
|
|
40
|
+
the last commit. Those are the same thing only on a clean tree.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from .guard import (
|
|
44
|
+
Guard,
|
|
45
|
+
RestoreFailed,
|
|
46
|
+
Interrupted,
|
|
47
|
+
guarded,
|
|
48
|
+
)
|
|
49
|
+
from .sentinel import Sentinel, Drift
|
|
50
|
+
|
|
51
|
+
__all__ = [
|
|
52
|
+
"guarded",
|
|
53
|
+
"Guard",
|
|
54
|
+
"RestoreFailed",
|
|
55
|
+
"Interrupted",
|
|
56
|
+
"Sentinel",
|
|
57
|
+
"Drift",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
__version__ = "0.0.1"
|