Skip to main content

restore-verified

Temporarily modify a file, survive the signal, and prove the tree came back.

For anything that breaks a file on purpose and puts it back: a mutation harness, a codemod, a benchmark that swaps a config, a test that patches a fixture.

pip install restore-verified      # the Python half
npm install restore-verified      # the JavaScript half
from restore_verified import guarded

with guarded("src/parser.py") as g:
    g.write(g.read().replace("<=", "<"))
    run_the_suite()
# restored here — and the restore is checked, byte for byte
import { guarded } from "restore-verified";

await guarded("src/parser.js", async (g) => {
  g.write(g.read().replace("<=", "<"));
  await runTheSuite();
});
// restored here — and the restore is checked, byte for byte

JavaScript has no with, so the callback is what brackets the work. It is not sugar: it is the only shape that puts the restore in a finally the caller cannot forget.

# the case nothing else covers: the tool is SIGKILLed by a timeout mid-edit
restore-verified run --paths src/ --timeout 600 --restore -- ./harness.sh

Read this first: on a clean git checkout, use git

git diff --quiet already catches an unrestored change and git checkout -- FILE already fixes it. That is free, it is correct, and it is what you should do. This package is for the cases where it is not true — and those are not exotic:

clean tree dirty tree (a developer's checkout)
git diff --quiet before clean DIRTY
git diff --quiet after a failed restore DIRTY — caught DIRTY — indistinguishable
git checkout -- FILE restores destroys the uncommitted work

Both rows are asserted in tests/test_guard.py::TheGitControl, including the one that says if git preserved the uncommitted work, use git.

The reason is structural, not incidental: a snapshot here is per-file and taken when you start; git's is repo-wide and taken at the last commit. Those are the same thing only on a clean tree. The other cases git cannot serve at all are untracked or ignored files — generated code, fetched fixtures, local config — and not being in a repository: a container, an installed package, an unpacked tarball.

The four failures, and which layer covers each

what covers it what happens without it
an exception mid-run try/finally — and every in-place-edit package on either registry the file stays broken
a signal this package's Guard finally does not run on SIGTERM. No handler, no unwinding; the file stays broken
the restore itself being wrong this package's verification a restore that ran is not a restore that worked
SIGKILL / a timeout this package's Sentinel, one process outward nothing in-process can help; the file stays broken

The second row is measured, not asserted. tests/child.py runs the same mutation three ways in a real subprocess, and the test suite kills it for real:

test_SIGTERM_leaves_a_try_finally_harness_broken ......... ok
test_SIGTERM_does_not_leave_a_guarded_harness_broken ..... ok
test_the_signal_is_re_delivered_so_a_kill_still_kills .... ok
test_SIGKILL_defeats_the_guard_and_the_sentinel_catches_it ok

The first of those is the control. If try/finally ever survives SIGTERM, the premise of this package is wrong and the test says so in those words.

The third row is the name

in-place (PyPI) restores the original if an exception occurs. fs-transaction (PyPI) rolls back a failed write. A second sweep — PyPI's full 881,198-name index for restore, rollback, revert, atomic, sigterm and in-place, plus web search for the combination — turned up nothing further. atomically and write-file-atomic (npm, 16M and more downloads a week) make a write all-or-nothing. None of them re-reads what it put back. A restore can run perfectly and still be wrong: a buffer captured after mutating, a different encoding on the way out, one of the two files you touched. All three leave the restore path looking healthy, and every run after them scores code nobody wrote. Hashing before and comparing after is the only check that separates ran from worked.

The fourth row is the one with no incumbent anywhere

SIGKILL cannot be caught, blocked or handled. The ordinary way to be SIGKILLed is not an impatient person — it is a timeout. subprocess.run(..., timeout=...) calls Popen.kill() when the deadline passes, and so does the kill step of a CI runner that has waited long enough. A harness carrying a perfect in-process guard, invoked under a timeout it exceeds, leaves the tree exactly as broken as one carrying no guard at all:

$ restore-verified run --paths /tmp/demo/m.py --timeout 2 -- python3 harness.py
[restore-verified] the command exceeded 2.0s and was SIGKILLed — no handler, no `finally`, no cleanup ran
THE TREE DID NOT COME BACK — 1 file(s):
  changed  /tmp/demo/m.py — 27 bytes -> 8 bytes, digest 64545701caa5 -> 14309db042d4

  Everything measured after this point scores code nobody wrote.
  Re-run with --restore to put them back from the snapshot.
$ echo $?
3

That harness had a flawless guard. The check has to live in whatever invoked it.

Two halves, one manifest

The half that breaks a tree and the half that checks it came back are frequently not the same process, and frequently not the same language — a Node build script invoking a Python codemod, a Python CI harness shelling out to jscodeshift. So Sentinel writes one document, and that is a claim about bytes rather than about intentions:

# a Python harness records, a Node script verifies — or the other way round
python3 -m restore_verified.cli record --paths src/ --manifest before.json
npx restore-verified verify --manifest before.json

python/tests/test_parity.py asserts it in both directions: Python writes a manifest and JavaScript verifies against it, JavaScript writes one and Python restores from it, and both halves produce byte-identical manifests for the same tree. Verdicts agreeing proves the two read the document the same way; bytes agreeing proves they write it the same way, which is what stops one half quietly adding a field the other ignores. The version number, the drift exit code and the skipped-directory list are asserted equal too.

What is different in JavaScript, and why

Two things, and neither is cosmetic.

A signal handler cannot unwind an awaited body. In Python the handler raises, the with block unwinds, and the restore happens on the ordinary path. Node has no such path — a handler runs as its own event-loop task and cannot inject an exception into whatever the body is awaiting. So the JavaScript handler performs the restore itself, synchronously, which is why every filesystem call in that half is the ...Sync one.

Registering a handler prevents the default termination. process.on("SIGTERM", ...) means the process no longer dies on SIGTERM, so a guard that catches, restores and returns has converted kill into "nothing happened". The handler therefore removes itself and re-raises the signal — and that turned out to have an edge of its own: a registered signal listener is also a handle keeping the event loop alive, so removing the last one can leave the loop empty and Node exits before the re-raised signal lands. A timer held across the re-raise closes it. Found by a failing test, not by reading.

Design decisions worth knowing

A caught signal is re-delivered. Swallowing SIGTERM turns kill into "nothing happened", which is a worse bug than the one being fixed. The guard restores the file, puts the handler back to the default, and re-raises the signal at itself — so the process dies with status -15, as the sender intended. Asserted.

Interrupted inherits from BaseException. A bare except Exception: inside the guarded body — ordinary defensive code — would otherwise swallow the interruption and keep running against a mutated tree after someone asked it to stop.

Nothing is written beside the code under test. The snapshot lives in a temp directory, not in foo.py.bak. A scratch file in the directory being measured changes what a file walker collects, what a test runner discovers, and what a coverage denominator counts. A clean target is not a clean tree.

g.read() returns the original, from the snapshot. Reading the file back after mutating it and calling that "the original" is one of the three ways a restore runs and does not work; taking it from the snapshot makes the mistake unavailable.

mtime is restored too (pass restore_mtime=False to opt out). A build system, a test cache and a file watcher all key on mtime, and a guard that triggers a full rebuild on every run is a guard people switch off.

The tension, found by building canfail on top of this. If your tool compiles or imports the file it just restored, restoring mtime is wrong: a bytecode cache written from the broken source then looks fresh. Worse, restore_mtime=False is not sufficient either — mtime invalidation has one-second granularity, and an edit/run/restore cycle in milliseconds defeats it whichever way you set this. Disable the cache (PYTHONDONTWRITEBYTECODE=1, make -B) rather than relying on the clock.

Off the main thread it says so. signal.signal only works on the main thread, so there the guard degrades to a try/finally — and sets g.signal_note to explain it, rather than covering half the job silently.

Three drift outcomes, not two. changed, missing and created are kept apart: a missing file and a changed one send you to opposite ends of the problem.

A digest-only manifest refuses to pretend it can restore. Sentinel.record on a directory keeps digests and no content by default, so verify() works and restore() raises rather than silently doing nothing.

API

from restore_verified import guarded, Sentinel, RestoreFailed

with guarded("a.py", "b.py") as g:       # one file or many
    g.write("a.py", mutated_text)
    ...
# RestoreFailed if anything did not come back byte for byte

sentinel = Sentinel.record(["src/"])      # digests; add keep_content=True to restore
manifest = sentinel.save()                # survives the process that broke the tree
...
for drift in Sentinel.load(manifest).verify():
    print(drift)
import { guarded, Sentinel, RestoreFailed } from "restore-verified";

await guarded(["a.js", "b.js"], async (g) => {   // one file or many
  g.write("a.js", mutatedText);
});
// RestoreFailed if anything did not come back byte for byte

const sentinel = Sentinel.record(["src/"]);       // add { keepContent: true } to restore
const manifest = sentinel.save();
for (const drift of Sentinel.load(manifest).verify()) console.log(String(drift));

Both halves ship the same CLI, with the same flags and the same exit codes — a CI file should not have to ask which one is installed:

restore-verified run --paths src/ [--timeout N] [--restore] -- CMD...
restore-verified record --paths src/ --manifest before.json
restore-verified verify --manifest before.json [--restore]

Exit code 3 means the tree did not come back — its own code, never folded into the command's status, because a harness that exits 0 having left a file mutated is the exact failure this exists to report.

--restore still exits 3. That this command could put the tree back makes the run recoverable, not trustworthy.

Scope, honestly

  • Mature mutation frameworks do not need this. mutmut 3 copies source_paths to a mutants/ directory and mutates the copy; StrykerJS sandboxes likewise. Avoiding in-place mutation is a better answer than guarding it, and if you can restructure that way, do. This is for the tools that cannot — hand-rolled harnesses, codemods that must run against the real tree, anything whose build config points at the original path.
  • Zero dependencies, standard library only, Python 3.9+.
  • POSIX signals. On Windows there is no SIGTERM in the POSIX sense; the guard covers exceptions and Sentinel covers the rest.
  • It does not lock. Two processes guarding the same file will not see each other.
  • Sentinel.record on a large tree costs one SHA-256 read per file.

Tests

python3 -m unittest discover -s python/tests   # 32, including the cross-half contract
npm test                                       # 22

No dependencies in either half. The signal tests spawn a real child and really kill it, because the question is not "does the handler run" but "what does the file on disk look like after somebody types kill".

Five mutations to the source were applied — with this package's own guard — and all five were caught by the test that should catch them: removing the signal installation, removing the verification, dropping the re-delivery, keeping the snapshot beside the code, and making verify always report clean.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

restore_verified-0.1.1.tar.gz (22.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

restore_verified-0.1.1-py3-none-any.whl (20.7 kB view details)

Uploaded Python 3

File details

Details for the file restore_verified-0.1.1.tar.gz.

File metadata

  • Download URL: restore_verified-0.1.1.tar.gz
  • Upload date:
  • Size: 22.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for restore_verified-0.1.1.tar.gz
Algorithm Hash digest
SHA256 69b9ad025eeea97f71752e103b9fac6fc57c70cb03daba4ceaecd367e2fbd851
MD5 a966687346649a7af6499b6c48eac1c0
BLAKE2b-256 a5af92572edaf979f829bb15b8a81354b9a5032e2021318368ac77a166e4230f

See more details on using hashes here.

Provenance

The following attestation bundles were made for restore_verified-0.1.1.tar.gz:

Publisher: release.yml on Megapixel99/restore-verified

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file restore_verified-0.1.1-py3-none-any.whl.

File metadata

File hashes

Hashes for restore_verified-0.1.1-py3-none-any.whl
Algorithm Hash digest
SHA256 6afcefdd6d9b5c5777969d80f63f968fe73fc36b4f6201eb70b90ed3f1a7243d
MD5 875f6a3e165a574a3750201970547308
BLAKE2b-256 518018ce7498b8c1f9a37e140ede0d0d7728e9b508e1ca91edbc8e900dba3562

See more details on using hashes here.

Provenance

The following attestation bundles were made for restore_verified-0.1.1-py3-none-any.whl:

Publisher: release.yml on Megapixel99/restore-verified

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.1.4

2 files

0.1.3

2 files

0.1.2

2 files

This release

0.1.1 This release

2 files

0.0.1

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page