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.
@@ -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"