escrow-evidence 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ritish Saini
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,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: escrow-evidence
3
+ Version: 0.1.0
4
+ Summary: A dead-man's-switch for cron/scheduled jobs: flags silence, not just failure -- the job that stopped running weeks ago that no alert ever caught.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/MaXiMo000/escrow
7
+ Project-URL: Source, https://github.com/MaXiMo000/escrow
8
+ Project-URL: Issues, https://github.com/MaXiMo000/escrow/issues
9
+ Project-URL: Changelog, https://github.com/MaXiMo000/escrow/releases
10
+ Keywords: cron,monitoring,dead-mans-switch,heartbeat,evidence,reliability
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Monitoring
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: PyYAML>=6.0
22
+ Dynamic: license-file
23
+
24
+ # escrow
25
+
26
+ [![ci](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml)
27
+
28
+ **A dead-man's-switch for cron jobs, GitHub Actions schedules, and systemd
29
+ timers: flags silence, not just failure.**
30
+
31
+ A scheduled job that throws an exception gets logged, maybe alerted on.
32
+ One that silently *stops running* — a cron entry removed by a bad deploy,
33
+ a systemd timer disabled and forgotten, a token that expired weeks ago —
34
+ produces nothing: no error, no log line, no exit code to check. "No
35
+ alert" and "everything's fine" look identical to anything that only
36
+ watches for failure. `escrow` watches for the third thing: quiet.
37
+
38
+ ```
39
+ $ escrow check escrow.yaml
40
+ [!!] 'nightly-backup' last pinged 1d ago, past its 26h interval
41
+ [??] 'weekly-report' has never pinged in -- it may have never run, or is pinging under a different name
42
+
43
+ 0/2 ok, 2 need attention
44
+ ```
45
+
46
+ (Real output. `nightly-backup` pinged in on time once, then its clock was
47
+ wound forward 30 hours past its 26h interval; `weekly-report` never pinged
48
+ at all.)
49
+
50
+ ## Install
51
+
52
+ ```
53
+ pip install escrow-evidence # the command it installs is `escrow`
54
+ ```
55
+
56
+ (`escrow` was already taken on PyPI -- same story as every sibling in this
57
+ portfolio.)
58
+
59
+ ## Use
60
+
61
+ Declare what you're expecting, in `escrow.yaml`:
62
+
63
+ ```yaml
64
+ jobs:
65
+ - name: nightly-backup
66
+ interval: 26h # a daily job, with a few hours' slack
67
+ - name: weekly-report
68
+ interval: 8d
69
+ ```
70
+
71
+ At the end of the actual job's script, record that it ran:
72
+
73
+ ```bash
74
+ escrow ping nightly-backup
75
+ ```
76
+
77
+ Separately -- on its own schedule, e.g. every 15 minutes -- check every
78
+ declared job against what's actually been recorded:
79
+
80
+ ```bash
81
+ escrow check escrow.yaml
82
+ ```
83
+
84
+ `ping` and `check` share a small JSON state file (`escrow-state.json` by
85
+ default, or `--state PATH`) -- `ping` writes to it, `check` reads it. They
86
+ need to see the *same* file: the common shape is a single host (the job
87
+ and the check both run there, e.g. two cron entries) or a shared
88
+ filesystem mount. This is not a hosted, multi-machine service; see "What
89
+ this does not do."
90
+
91
+ ## Three statuses, not two
92
+
93
+ | Status | Meaning |
94
+ |---|---|
95
+ | `ok` | Pinged within its declared interval. |
96
+ | `overdue` | Pinged before, but not recently enough -- **the job that used to run and stopped.** |
97
+ | `never_seen` | Declared in `escrow.yaml`, never once recorded a ping -- **the job that never ran at all**, or a typo between the name in the config and the name in the script. |
98
+
99
+ `overdue` and `never_seen` are deliberately different statuses, not one
100
+ "bad" bucket: an operator debugs "this stopped" and "this never started"
101
+ differently, and collapsing them would hide which one they're looking at.
102
+ Jobs are declared in `escrow.yaml` up front, not discovered from whatever
103
+ happens to have pinged -- a job that's only "known" because it once pinged
104
+ would be exactly as invisible as before the first time it silently
105
+ stopped, or if it never started.
106
+
107
+ Exit code is `1` if anything is `overdue` or `never_seen`, `0` if every
108
+ declared job is `ok`.
109
+
110
+ ## What this does NOT do
111
+
112
+ - **No daemon, no background process.** There is no `escrow serve` or
113
+ `escrow watch`. `escrow check` is one CLI invocation that reads the
114
+ state file and exits; *you* supply the periodic trigger -- a cron
115
+ entry, a systemd timer, a scheduled GitHub Actions workflow. escrow
116
+ never runs unless something else runs it.
117
+ - **No email, Slack, or webhook of its own.** The exit code is the
118
+ interface -- same convention [`receipt`](https://github.com/MaXiMo000/receipt),
119
+ [`invariant`](https://github.com/MaXiMo000/invariant), and
120
+ [`carabiner`](https://github.com/MaXiMo000/carabiner) already share.
121
+ Run `escrow check` as a step that fails loudly in whatever you already
122
+ have (a GitHub Actions job, a systemd `OnFailure=` unit, a cron entry
123
+ piped to your existing paging tool) rather than escrow adding its own
124
+ SMTP client or HTTP dependency for a notification path you may not want.
125
+ - **Not a hosted or multi-machine service.** State is one JSON file; `ping`
126
+ and `check` need to see the same one. A fleet of machines all pinging a
127
+ shared endpoint needs a real datastore behind it, not a local file --
128
+ genuinely different scope, not built here.
129
+ - **Not resilient to concurrent writers.** State is written to a temp file
130
+ and atomically renamed (a crash mid-write can't corrupt it), but two
131
+ `ping`s racing on the exact same job name on a network filesystem at the
132
+ same instant can still lose one update. Fine for the target use --one
133
+ job pings once per run-- not designed for high-frequency concurrent
134
+ writes.
135
+
136
+ ## Compared to a hosted dead-man's-switch
137
+
138
+ [healthchecks.io](https://healthchecks.io), [Cronitor](https://cronitor.io),
139
+ and [Dead Man's Snitch](https://deadmanssnitch.com) solve the same problem
140
+ as a real, mature, hosted service: your job pings a URL over HTTPS, and
141
+ their infrastructure -- not yours -- watches the clock and sends email/
142
+ Slack/SMS/PagerDuty when a ping is late. If you want alerting that works
143
+ without you also solving alerting, and don't mind a third party knowing
144
+ when your jobs run, one of those is very likely the better choice --
145
+ escrow doesn't compete with that and isn't trying to.
146
+
147
+ escrow's tradeoff runs the other way, matching the same "stays on your
148
+ machine" discipline as the rest of this portfolio: `ping` and `check` never
149
+ leave the filesystem, there's no account, no third party ever learns your
150
+ job names or schedule, and the whole state is one JSON file you can read,
151
+ back up, or delete yourself. The cost of that is everything a hosted
152
+ service gives you for free: no scheduling (see "no daemon," above) and no
153
+ notification path of its own (see above) -- you supply both, from
154
+ infrastructure you already have. Reach for escrow specifically when a
155
+ third-party dependency for "is my cron job still running" is the wrong
156
+ tradeoff for what the job actually does; reach for a hosted switch
157
+ otherwise.
158
+
159
+ ## Tests
160
+
161
+ ```
162
+ pip install -e .
163
+ python tests/test_duration.py # "26h", "8d" -> seconds
164
+ python tests/test_config.py # escrow.yaml validation
165
+ python tests/test_state.py # the ping record: real files, real temp dirs
166
+ python tests/test_check.py # ok / overdue / never_seen classification
167
+ python tests/test_cli.py # the real CLI entry point, real files, real argv
168
+ ```
169
+
170
+ 41 tests. Two exist because testing an actual misconfigured `--state`
171
+ (pointed at a directory instead of a file) found a real gap: `load_state`
172
+ only caught `JSONDecodeError`, so `IsADirectoryError` -- also an `OSError`
173
+ -- escaped as a raw traceback instead of the same graceful "nothing
174
+ recorded yet" every other unreadable state file gets.
175
+
176
+ MIT licensed.
@@ -0,0 +1,153 @@
1
+ # escrow
2
+
3
+ [![ci](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml)
4
+
5
+ **A dead-man's-switch for cron jobs, GitHub Actions schedules, and systemd
6
+ timers: flags silence, not just failure.**
7
+
8
+ A scheduled job that throws an exception gets logged, maybe alerted on.
9
+ One that silently *stops running* — a cron entry removed by a bad deploy,
10
+ a systemd timer disabled and forgotten, a token that expired weeks ago —
11
+ produces nothing: no error, no log line, no exit code to check. "No
12
+ alert" and "everything's fine" look identical to anything that only
13
+ watches for failure. `escrow` watches for the third thing: quiet.
14
+
15
+ ```
16
+ $ escrow check escrow.yaml
17
+ [!!] 'nightly-backup' last pinged 1d ago, past its 26h interval
18
+ [??] 'weekly-report' has never pinged in -- it may have never run, or is pinging under a different name
19
+
20
+ 0/2 ok, 2 need attention
21
+ ```
22
+
23
+ (Real output. `nightly-backup` pinged in on time once, then its clock was
24
+ wound forward 30 hours past its 26h interval; `weekly-report` never pinged
25
+ at all.)
26
+
27
+ ## Install
28
+
29
+ ```
30
+ pip install escrow-evidence # the command it installs is `escrow`
31
+ ```
32
+
33
+ (`escrow` was already taken on PyPI -- same story as every sibling in this
34
+ portfolio.)
35
+
36
+ ## Use
37
+
38
+ Declare what you're expecting, in `escrow.yaml`:
39
+
40
+ ```yaml
41
+ jobs:
42
+ - name: nightly-backup
43
+ interval: 26h # a daily job, with a few hours' slack
44
+ - name: weekly-report
45
+ interval: 8d
46
+ ```
47
+
48
+ At the end of the actual job's script, record that it ran:
49
+
50
+ ```bash
51
+ escrow ping nightly-backup
52
+ ```
53
+
54
+ Separately -- on its own schedule, e.g. every 15 minutes -- check every
55
+ declared job against what's actually been recorded:
56
+
57
+ ```bash
58
+ escrow check escrow.yaml
59
+ ```
60
+
61
+ `ping` and `check` share a small JSON state file (`escrow-state.json` by
62
+ default, or `--state PATH`) -- `ping` writes to it, `check` reads it. They
63
+ need to see the *same* file: the common shape is a single host (the job
64
+ and the check both run there, e.g. two cron entries) or a shared
65
+ filesystem mount. This is not a hosted, multi-machine service; see "What
66
+ this does not do."
67
+
68
+ ## Three statuses, not two
69
+
70
+ | Status | Meaning |
71
+ |---|---|
72
+ | `ok` | Pinged within its declared interval. |
73
+ | `overdue` | Pinged before, but not recently enough -- **the job that used to run and stopped.** |
74
+ | `never_seen` | Declared in `escrow.yaml`, never once recorded a ping -- **the job that never ran at all**, or a typo between the name in the config and the name in the script. |
75
+
76
+ `overdue` and `never_seen` are deliberately different statuses, not one
77
+ "bad" bucket: an operator debugs "this stopped" and "this never started"
78
+ differently, and collapsing them would hide which one they're looking at.
79
+ Jobs are declared in `escrow.yaml` up front, not discovered from whatever
80
+ happens to have pinged -- a job that's only "known" because it once pinged
81
+ would be exactly as invisible as before the first time it silently
82
+ stopped, or if it never started.
83
+
84
+ Exit code is `1` if anything is `overdue` or `never_seen`, `0` if every
85
+ declared job is `ok`.
86
+
87
+ ## What this does NOT do
88
+
89
+ - **No daemon, no background process.** There is no `escrow serve` or
90
+ `escrow watch`. `escrow check` is one CLI invocation that reads the
91
+ state file and exits; *you* supply the periodic trigger -- a cron
92
+ entry, a systemd timer, a scheduled GitHub Actions workflow. escrow
93
+ never runs unless something else runs it.
94
+ - **No email, Slack, or webhook of its own.** The exit code is the
95
+ interface -- same convention [`receipt`](https://github.com/MaXiMo000/receipt),
96
+ [`invariant`](https://github.com/MaXiMo000/invariant), and
97
+ [`carabiner`](https://github.com/MaXiMo000/carabiner) already share.
98
+ Run `escrow check` as a step that fails loudly in whatever you already
99
+ have (a GitHub Actions job, a systemd `OnFailure=` unit, a cron entry
100
+ piped to your existing paging tool) rather than escrow adding its own
101
+ SMTP client or HTTP dependency for a notification path you may not want.
102
+ - **Not a hosted or multi-machine service.** State is one JSON file; `ping`
103
+ and `check` need to see the same one. A fleet of machines all pinging a
104
+ shared endpoint needs a real datastore behind it, not a local file --
105
+ genuinely different scope, not built here.
106
+ - **Not resilient to concurrent writers.** State is written to a temp file
107
+ and atomically renamed (a crash mid-write can't corrupt it), but two
108
+ `ping`s racing on the exact same job name on a network filesystem at the
109
+ same instant can still lose one update. Fine for the target use --one
110
+ job pings once per run-- not designed for high-frequency concurrent
111
+ writes.
112
+
113
+ ## Compared to a hosted dead-man's-switch
114
+
115
+ [healthchecks.io](https://healthchecks.io), [Cronitor](https://cronitor.io),
116
+ and [Dead Man's Snitch](https://deadmanssnitch.com) solve the same problem
117
+ as a real, mature, hosted service: your job pings a URL over HTTPS, and
118
+ their infrastructure -- not yours -- watches the clock and sends email/
119
+ Slack/SMS/PagerDuty when a ping is late. If you want alerting that works
120
+ without you also solving alerting, and don't mind a third party knowing
121
+ when your jobs run, one of those is very likely the better choice --
122
+ escrow doesn't compete with that and isn't trying to.
123
+
124
+ escrow's tradeoff runs the other way, matching the same "stays on your
125
+ machine" discipline as the rest of this portfolio: `ping` and `check` never
126
+ leave the filesystem, there's no account, no third party ever learns your
127
+ job names or schedule, and the whole state is one JSON file you can read,
128
+ back up, or delete yourself. The cost of that is everything a hosted
129
+ service gives you for free: no scheduling (see "no daemon," above) and no
130
+ notification path of its own (see above) -- you supply both, from
131
+ infrastructure you already have. Reach for escrow specifically when a
132
+ third-party dependency for "is my cron job still running" is the wrong
133
+ tradeoff for what the job actually does; reach for a hosted switch
134
+ otherwise.
135
+
136
+ ## Tests
137
+
138
+ ```
139
+ pip install -e .
140
+ python tests/test_duration.py # "26h", "8d" -> seconds
141
+ python tests/test_config.py # escrow.yaml validation
142
+ python tests/test_state.py # the ping record: real files, real temp dirs
143
+ python tests/test_check.py # ok / overdue / never_seen classification
144
+ python tests/test_cli.py # the real CLI entry point, real files, real argv
145
+ ```
146
+
147
+ 41 tests. Two exist because testing an actual misconfigured `--state`
148
+ (pointed at a directory instead of a file) found a real gap: `load_state`
149
+ only caught `JSONDecodeError`, so `IsADirectoryError` -- also an `OSError`
150
+ -- escaped as a raw traceback instead of the same graceful "nothing
151
+ recorded yet" every other unreadable state file gets.
152
+
153
+ MIT licensed.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,53 @@
1
+ """Compare declared jobs against recorded pings: ok, overdue, or
2
+ never_seen. Silence gets its own status, distinct from both -- the entire
3
+ reason this tool exists is that "no alert" and "everything's fine" look
4
+ identical to anything that only watches for a failure exit code.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ OK, OVERDUE, NEVER_SEEN = "ok", "overdue", "never_seen"
9
+
10
+
11
+ def _fmt(seconds: float) -> str:
12
+ seconds = int(seconds)
13
+ if seconds < 60:
14
+ return f"{seconds}s"
15
+ if seconds < 3600:
16
+ return f"{seconds // 60}m"
17
+ if seconds < 86400:
18
+ return f"{seconds // 3600}h"
19
+ return f"{seconds // 86400}d"
20
+
21
+
22
+ def check_jobs(jobs: list[dict], state: dict, now: float) -> list[dict]:
23
+ results = []
24
+ for job in jobs:
25
+ name = job["name"]
26
+ record = state.get(name)
27
+
28
+ if record is None:
29
+ results.append({
30
+ "name": name, "status": NEVER_SEEN,
31
+ "detail": (f"'{name}' has never pinged in -- it may have never run, "
32
+ "or is pinging under a different name"),
33
+ "last_seen": None, "overdue_by_seconds": None,
34
+ })
35
+ continue
36
+
37
+ last_seen = record["last_seen"]
38
+ age = now - last_seen
39
+ if age > job["interval_seconds"]:
40
+ results.append({
41
+ "name": name, "status": OVERDUE,
42
+ "detail": (f"'{name}' last pinged {_fmt(age)} ago, past its "
43
+ f"{job['interval']} interval"),
44
+ "last_seen": last_seen,
45
+ "overdue_by_seconds": age - job["interval_seconds"],
46
+ })
47
+ else:
48
+ results.append({
49
+ "name": name, "status": OK,
50
+ "detail": f"'{name}' last pinged {_fmt(age)} ago, within its {job['interval']} interval",
51
+ "last_seen": last_seen, "overdue_by_seconds": None,
52
+ })
53
+ return results
@@ -0,0 +1,77 @@
1
+ """escrow ping <job-name> [--state PATH]
2
+ escrow check <config.yaml> [--state PATH] [--json]
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import json
8
+ import sys
9
+ import time
10
+
11
+ from .check import NEVER_SEEN, OK, OVERDUE, check_jobs
12
+ from .config import ConfigError, load_config
13
+ from .state import load_state, record_ping
14
+
15
+ DEFAULT_STATE = "escrow-state.json"
16
+ _TAG = {OK: "OK", OVERDUE: "!!", NEVER_SEEN: "??"}
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ parser = argparse.ArgumentParser(prog="escrow")
21
+ sub = parser.add_subparsers(dest="command", required=True)
22
+
23
+ ping_p = sub.add_parser("ping", help="record that a job ran, right now")
24
+ ping_p.add_argument("job_name")
25
+ ping_p.add_argument("--state", default=DEFAULT_STATE,
26
+ help=f"path to the state file (default: {DEFAULT_STATE})")
27
+
28
+ check_p = sub.add_parser(
29
+ "check", help="check every declared job against its recorded pings")
30
+ check_p.add_argument("config", help="escrow.yaml -- the declared jobs and intervals")
31
+ check_p.add_argument("--state", default=DEFAULT_STATE,
32
+ help=f"path to the state file (default: {DEFAULT_STATE})")
33
+ check_p.add_argument("--json", action="store_true", help="print the full report as JSON")
34
+
35
+ args = parser.parse_args(argv)
36
+
37
+ if args.command == "ping":
38
+ try:
39
+ record_ping(args.state, args.job_name, time.time())
40
+ except OSError as exc:
41
+ # --state pointed at something that can't be written (a
42
+ # directory, a read-only path) -- a wrong argument, not a
43
+ # crash. Silently swallowing this would be worse: it's the one
44
+ # write escrow makes, and its whole job is knowing whether that
45
+ # write actually happened.
46
+ sys.exit(f"escrow: could not record the ping: {exc}")
47
+ print(f"pinged '{args.job_name}'")
48
+ return 0
49
+
50
+ try:
51
+ jobs = load_config(args.config)
52
+ except ConfigError as exc:
53
+ sys.exit(f"escrow: {exc}")
54
+
55
+ state = load_state(args.state)
56
+ results = check_jobs(jobs, state, time.time())
57
+
58
+ if args.json:
59
+ print(json.dumps(results, indent=2))
60
+ else:
61
+ for r in results:
62
+ print(f"[{_TAG[r['status']]}] {r['detail']}")
63
+ n_bad = sum(1 for r in results if r["status"] != OK)
64
+ summary = f"{len(results) - n_bad}/{len(results)} ok"
65
+ if n_bad:
66
+ summary += f", {n_bad} need attention"
67
+ print(f"\n{summary}")
68
+
69
+ # Non-blocking by design: escrow reports, it doesn't page anyone --
70
+ # wire the exit code into whatever alerting already exists (a failing
71
+ # CI step, a systemd OnFailure= unit), the same "exit code is the
72
+ # interface" convention receipt/invariant/carabiner already share.
73
+ return 1 if any(r["status"] != OK for r in results) else 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ raise SystemExit(main())
@@ -0,0 +1,62 @@
1
+ """Load and validate escrow.yaml: the jobs expected to ping in, and how
2
+ often each one is allowed to go quiet before that's a problem.
3
+
4
+ Jobs must be declared up front, in a file `check` reads -- a job that is
5
+ only ever known because it once pinged would be exactly as invisible as
6
+ before the first time it silently stopped running, or if it never started
7
+ in the first place. Declaring first and checking reality against the
8
+ declaration is the same discipline `invariant` already applies to
9
+ database state, applied here to "did this job show up at all."
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import yaml
14
+
15
+ from .duration import DurationError, parse_duration
16
+
17
+
18
+ class ConfigError(ValueError):
19
+ pass
20
+
21
+
22
+ def load_config(path: str) -> list[dict]:
23
+ try:
24
+ with open(path, encoding="utf-8") as f:
25
+ raw = yaml.safe_load(f)
26
+ except yaml.YAMLError as exc:
27
+ raise ConfigError(f"{path}: not valid YAML ({exc})") from exc
28
+ except OSError as exc:
29
+ raise ConfigError(f"{path}: {exc}") from exc
30
+
31
+ if not isinstance(raw, dict) or "jobs" not in raw:
32
+ raise ConfigError(f"{path}: must be a mapping with a top-level 'jobs' list")
33
+ jobs = raw["jobs"]
34
+ if not isinstance(jobs, list) or not jobs:
35
+ raise ConfigError(f"{path}: 'jobs' must be a non-empty list")
36
+
37
+ parsed = []
38
+ seen = set()
39
+ for i, job in enumerate(jobs):
40
+ if not isinstance(job, dict):
41
+ raise ConfigError(f"{path}: jobs[{i}] must be a mapping")
42
+ name = job.get("name")
43
+ if not name or not isinstance(name, str):
44
+ raise ConfigError(f"{path}: jobs[{i}] is missing a string 'name'")
45
+ if name in seen:
46
+ raise ConfigError(f"{path}: duplicate job name '{name}'")
47
+ seen.add(name)
48
+
49
+ interval = job.get("interval")
50
+ if not interval:
51
+ raise ConfigError(f"{path}: job '{name}' is missing 'interval'")
52
+ try:
53
+ interval_seconds = parse_duration(str(interval))
54
+ except DurationError as exc:
55
+ raise ConfigError(f"{path}: job '{name}': {exc}") from exc
56
+
57
+ parsed.append({
58
+ "name": name,
59
+ "interval": str(interval),
60
+ "interval_seconds": interval_seconds,
61
+ })
62
+ return parsed
@@ -0,0 +1,31 @@
1
+ """Parse a human-friendly duration string ("26h", "8d", "45m", "2w") into
2
+ seconds.
3
+
4
+ Single unit only for v1 -- "1d12h" isn't supported; write "36h" instead.
5
+ Combined-unit parsing is a real feature and not hard to add, but every
6
+ config in escrow.yaml only ever needs "how long can this job go quiet
7
+ before that's a problem," which a single unit answers just as well and
8
+ without ambiguity about ordering (is "12h1d" valid? "1d 12h"?) that a
9
+ combined format would have to define and test.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import re
14
+
15
+ _UNIT_SECONDS = {"s": 1, "m": 60, "h": 3600, "d": 86400, "w": 604800}
16
+ _PATTERN = re.compile(r'^(\d+(?:\.\d+)?)([smhdw])$')
17
+
18
+
19
+ class DurationError(ValueError):
20
+ pass
21
+
22
+
23
+ def parse_duration(text: str) -> float:
24
+ m = _PATTERN.match(text.strip())
25
+ if not m:
26
+ raise DurationError(
27
+ f"'{text}' is not a duration escrow understands -- use a number "
28
+ f"followed by one unit: s, m, h, d, or w (e.g. '26h', '8d'). "
29
+ f"Combined units like '1d12h' aren't supported; write '36h'.")
30
+ value, unit = m.groups()
31
+ return float(value) * _UNIT_SECONDS[unit]
@@ -0,0 +1,54 @@
1
+ """Where each job's last ping is recorded: one small JSON file.
2
+
3
+ Built for the common case -- a single host (or a shared filesystem both
4
+ the job and the checker can see) -- not for concurrent writers racing on a
5
+ network filesystem. A cron job and a monitoring check on the same box, or
6
+ a NAS-mounted state file, are the target; a distributed fleet of machines
7
+ all pinging the same file needs a real datastore, not this. Said plainly
8
+ in README rather than silently outgrown.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import pathlib
15
+ import tempfile
16
+
17
+
18
+ def load_state(path: str) -> dict:
19
+ p = pathlib.Path(path)
20
+ if not p.exists():
21
+ return {}
22
+ try:
23
+ return json.loads(p.read_text(encoding="utf-8"))
24
+ except (OSError, json.JSONDecodeError):
25
+ # A corrupted state file, or --state pointed at something that
26
+ # isn't a plain file at all (a directory, a broken symlink), reads
27
+ # as "nothing has ever pinged," not a crash -- the next real ping
28
+ # repairs it, and in the meantime every job just reads as
29
+ # never_seen, which is honest: this file is exactly what would
30
+ # tell us otherwise, and it can't right now.
31
+ return {}
32
+
33
+
34
+ def save_state(path: str, state: dict) -> None:
35
+ p = pathlib.Path(path)
36
+ p.parent.mkdir(parents=True, exist_ok=True)
37
+ # Write to a temp file in the same directory, then atomically rename --
38
+ # a crash mid-write leaves the previous, valid state file intact
39
+ # instead of a half-written, unparseable one.
40
+ fd, tmp_path = tempfile.mkstemp(dir=p.parent, prefix=".escrow-", suffix=".tmp")
41
+ try:
42
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
43
+ json.dump(state, f, indent=2, sort_keys=True)
44
+ os.replace(tmp_path, p)
45
+ except BaseException:
46
+ if os.path.exists(tmp_path):
47
+ os.unlink(tmp_path)
48
+ raise
49
+
50
+
51
+ def record_ping(path: str, job_name: str, when: float) -> None:
52
+ state = load_state(path)
53
+ state[job_name] = {"last_seen": when}
54
+ save_state(path, state)
@@ -0,0 +1,176 @@
1
+ Metadata-Version: 2.4
2
+ Name: escrow-evidence
3
+ Version: 0.1.0
4
+ Summary: A dead-man's-switch for cron/scheduled jobs: flags silence, not just failure -- the job that stopped running weeks ago that no alert ever caught.
5
+ License-Expression: MIT
6
+ Project-URL: Homepage, https://github.com/MaXiMo000/escrow
7
+ Project-URL: Source, https://github.com/MaXiMo000/escrow
8
+ Project-URL: Issues, https://github.com/MaXiMo000/escrow/issues
9
+ Project-URL: Changelog, https://github.com/MaXiMo000/escrow/releases
10
+ Keywords: cron,monitoring,dead-mans-switch,heartbeat,evidence,reliability
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Topic :: System :: Monitoring
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: PyYAML>=6.0
22
+ Dynamic: license-file
23
+
24
+ # escrow
25
+
26
+ [![ci](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml/badge.svg)](https://github.com/MaXiMo000/escrow/actions/workflows/ci.yml)
27
+
28
+ **A dead-man's-switch for cron jobs, GitHub Actions schedules, and systemd
29
+ timers: flags silence, not just failure.**
30
+
31
+ A scheduled job that throws an exception gets logged, maybe alerted on.
32
+ One that silently *stops running* — a cron entry removed by a bad deploy,
33
+ a systemd timer disabled and forgotten, a token that expired weeks ago —
34
+ produces nothing: no error, no log line, no exit code to check. "No
35
+ alert" and "everything's fine" look identical to anything that only
36
+ watches for failure. `escrow` watches for the third thing: quiet.
37
+
38
+ ```
39
+ $ escrow check escrow.yaml
40
+ [!!] 'nightly-backup' last pinged 1d ago, past its 26h interval
41
+ [??] 'weekly-report' has never pinged in -- it may have never run, or is pinging under a different name
42
+
43
+ 0/2 ok, 2 need attention
44
+ ```
45
+
46
+ (Real output. `nightly-backup` pinged in on time once, then its clock was
47
+ wound forward 30 hours past its 26h interval; `weekly-report` never pinged
48
+ at all.)
49
+
50
+ ## Install
51
+
52
+ ```
53
+ pip install escrow-evidence # the command it installs is `escrow`
54
+ ```
55
+
56
+ (`escrow` was already taken on PyPI -- same story as every sibling in this
57
+ portfolio.)
58
+
59
+ ## Use
60
+
61
+ Declare what you're expecting, in `escrow.yaml`:
62
+
63
+ ```yaml
64
+ jobs:
65
+ - name: nightly-backup
66
+ interval: 26h # a daily job, with a few hours' slack
67
+ - name: weekly-report
68
+ interval: 8d
69
+ ```
70
+
71
+ At the end of the actual job's script, record that it ran:
72
+
73
+ ```bash
74
+ escrow ping nightly-backup
75
+ ```
76
+
77
+ Separately -- on its own schedule, e.g. every 15 minutes -- check every
78
+ declared job against what's actually been recorded:
79
+
80
+ ```bash
81
+ escrow check escrow.yaml
82
+ ```
83
+
84
+ `ping` and `check` share a small JSON state file (`escrow-state.json` by
85
+ default, or `--state PATH`) -- `ping` writes to it, `check` reads it. They
86
+ need to see the *same* file: the common shape is a single host (the job
87
+ and the check both run there, e.g. two cron entries) or a shared
88
+ filesystem mount. This is not a hosted, multi-machine service; see "What
89
+ this does not do."
90
+
91
+ ## Three statuses, not two
92
+
93
+ | Status | Meaning |
94
+ |---|---|
95
+ | `ok` | Pinged within its declared interval. |
96
+ | `overdue` | Pinged before, but not recently enough -- **the job that used to run and stopped.** |
97
+ | `never_seen` | Declared in `escrow.yaml`, never once recorded a ping -- **the job that never ran at all**, or a typo between the name in the config and the name in the script. |
98
+
99
+ `overdue` and `never_seen` are deliberately different statuses, not one
100
+ "bad" bucket: an operator debugs "this stopped" and "this never started"
101
+ differently, and collapsing them would hide which one they're looking at.
102
+ Jobs are declared in `escrow.yaml` up front, not discovered from whatever
103
+ happens to have pinged -- a job that's only "known" because it once pinged
104
+ would be exactly as invisible as before the first time it silently
105
+ stopped, or if it never started.
106
+
107
+ Exit code is `1` if anything is `overdue` or `never_seen`, `0` if every
108
+ declared job is `ok`.
109
+
110
+ ## What this does NOT do
111
+
112
+ - **No daemon, no background process.** There is no `escrow serve` or
113
+ `escrow watch`. `escrow check` is one CLI invocation that reads the
114
+ state file and exits; *you* supply the periodic trigger -- a cron
115
+ entry, a systemd timer, a scheduled GitHub Actions workflow. escrow
116
+ never runs unless something else runs it.
117
+ - **No email, Slack, or webhook of its own.** The exit code is the
118
+ interface -- same convention [`receipt`](https://github.com/MaXiMo000/receipt),
119
+ [`invariant`](https://github.com/MaXiMo000/invariant), and
120
+ [`carabiner`](https://github.com/MaXiMo000/carabiner) already share.
121
+ Run `escrow check` as a step that fails loudly in whatever you already
122
+ have (a GitHub Actions job, a systemd `OnFailure=` unit, a cron entry
123
+ piped to your existing paging tool) rather than escrow adding its own
124
+ SMTP client or HTTP dependency for a notification path you may not want.
125
+ - **Not a hosted or multi-machine service.** State is one JSON file; `ping`
126
+ and `check` need to see the same one. A fleet of machines all pinging a
127
+ shared endpoint needs a real datastore behind it, not a local file --
128
+ genuinely different scope, not built here.
129
+ - **Not resilient to concurrent writers.** State is written to a temp file
130
+ and atomically renamed (a crash mid-write can't corrupt it), but two
131
+ `ping`s racing on the exact same job name on a network filesystem at the
132
+ same instant can still lose one update. Fine for the target use --one
133
+ job pings once per run-- not designed for high-frequency concurrent
134
+ writes.
135
+
136
+ ## Compared to a hosted dead-man's-switch
137
+
138
+ [healthchecks.io](https://healthchecks.io), [Cronitor](https://cronitor.io),
139
+ and [Dead Man's Snitch](https://deadmanssnitch.com) solve the same problem
140
+ as a real, mature, hosted service: your job pings a URL over HTTPS, and
141
+ their infrastructure -- not yours -- watches the clock and sends email/
142
+ Slack/SMS/PagerDuty when a ping is late. If you want alerting that works
143
+ without you also solving alerting, and don't mind a third party knowing
144
+ when your jobs run, one of those is very likely the better choice --
145
+ escrow doesn't compete with that and isn't trying to.
146
+
147
+ escrow's tradeoff runs the other way, matching the same "stays on your
148
+ machine" discipline as the rest of this portfolio: `ping` and `check` never
149
+ leave the filesystem, there's no account, no third party ever learns your
150
+ job names or schedule, and the whole state is one JSON file you can read,
151
+ back up, or delete yourself. The cost of that is everything a hosted
152
+ service gives you for free: no scheduling (see "no daemon," above) and no
153
+ notification path of its own (see above) -- you supply both, from
154
+ infrastructure you already have. Reach for escrow specifically when a
155
+ third-party dependency for "is my cron job still running" is the wrong
156
+ tradeoff for what the job actually does; reach for a hosted switch
157
+ otherwise.
158
+
159
+ ## Tests
160
+
161
+ ```
162
+ pip install -e .
163
+ python tests/test_duration.py # "26h", "8d" -> seconds
164
+ python tests/test_config.py # escrow.yaml validation
165
+ python tests/test_state.py # the ping record: real files, real temp dirs
166
+ python tests/test_check.py # ok / overdue / never_seen classification
167
+ python tests/test_cli.py # the real CLI entry point, real files, real argv
168
+ ```
169
+
170
+ 41 tests. Two exist because testing an actual misconfigured `--state`
171
+ (pointed at a directory instead of a file) found a real gap: `load_state`
172
+ only caught `JSONDecodeError`, so `IsADirectoryError` -- also an `OSError`
173
+ -- escaped as a raw traceback instead of the same graceful "nothing
174
+ recorded yet" every other unreadable state file gets.
175
+
176
+ MIT licensed.
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ escrow/__init__.py
5
+ escrow/check.py
6
+ escrow/cli.py
7
+ escrow/config.py
8
+ escrow/duration.py
9
+ escrow/state.py
10
+ escrow_evidence.egg-info/PKG-INFO
11
+ escrow_evidence.egg-info/SOURCES.txt
12
+ escrow_evidence.egg-info/dependency_links.txt
13
+ escrow_evidence.egg-info/entry_points.txt
14
+ escrow_evidence.egg-info/requires.txt
15
+ escrow_evidence.egg-info/top_level.txt
16
+ tests/test_check.py
17
+ tests/test_cli.py
18
+ tests/test_config.py
19
+ tests/test_duration.py
20
+ tests/test_state.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ escrow = escrow.cli:main
@@ -0,0 +1 @@
1
+ PyYAML>=6.0
@@ -0,0 +1,40 @@
1
+ [project]
2
+ # "escrow" was already taken on PyPI, same story as every sibling in this
3
+ # portfolio (checked, not assumed). The installed command stays the short
4
+ # name -- python-dateutil installs `dateutil`, this installs `escrow`.
5
+ name = "escrow-evidence"
6
+ version = "0.1.0"
7
+ description = "A dead-man's-switch for cron/scheduled jobs: flags silence, not just failure -- the job that stopped running weeks ago that no alert ever caught."
8
+ requires-python = ">=3.10"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ keywords = ["cron", "monitoring", "dead-mans-switch", "heartbeat", "evidence", "reliability"]
13
+ classifiers = [
14
+ "Development Status :: 3 - Alpha",
15
+ "Environment :: Console",
16
+ "Intended Audience :: Developers",
17
+ "Intended Audience :: System Administrators",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: System :: Monitoring",
21
+ ]
22
+ # PyYAML for escrow.yaml -- the same real dependency `invariant` already
23
+ # uses elsewhere in this portfolio for its own config file, not a new
24
+ # convention introduced here.
25
+ dependencies = ["PyYAML>=6.0"]
26
+
27
+ urls.Homepage = "https://github.com/MaXiMo000/escrow"
28
+ urls.Source = "https://github.com/MaXiMo000/escrow"
29
+ urls.Issues = "https://github.com/MaXiMo000/escrow/issues"
30
+ urls.Changelog = "https://github.com/MaXiMo000/escrow/releases"
31
+
32
+ [project.scripts]
33
+ escrow = "escrow.cli:main"
34
+
35
+ [build-system]
36
+ requires = ["setuptools>=77"]
37
+ build-backend = "setuptools.build_meta"
38
+
39
+ [tool.setuptools.packages.find]
40
+ include = ["escrow*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,66 @@
1
+ """Run: python tests/test_check.py"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import unittest
7
+
8
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
9
+
10
+ from escrow.check import NEVER_SEEN, OK, OVERDUE, check_jobs
11
+
12
+
13
+ def _job(name: str, interval: str, seconds: float) -> dict:
14
+ return {"name": name, "interval": interval, "interval_seconds": seconds}
15
+
16
+
17
+ class TestCheckJobs(unittest.TestCase):
18
+ def test_recently_pinged_job_is_ok(self):
19
+ jobs = [_job("nightly-backup", "26h", 26 * 3600)]
20
+ state = {"nightly-backup": {"last_seen": 1000.0}}
21
+ results = check_jobs(jobs, state, now=1000.0 + 3600) # 1h ago
22
+ self.assertEqual(results[0]["status"], OK)
23
+
24
+ def test_a_job_past_its_interval_is_overdue(self):
25
+ jobs = [_job("nightly-backup", "26h", 26 * 3600)]
26
+ state = {"nightly-backup": {"last_seen": 1000.0}}
27
+ results = check_jobs(jobs, state, now=1000.0 + 30 * 3600) # 30h ago
28
+ self.assertEqual(results[0]["status"], OVERDUE)
29
+ self.assertGreater(results[0]["overdue_by_seconds"], 0)
30
+
31
+ def test_exactly_at_the_interval_boundary_is_still_ok(self):
32
+ """The whole point of an interval is 'up to and including this
33
+ long is fine' -- past it, not at it, is what makes something
34
+ overdue."""
35
+ jobs = [_job("x", "1h", 3600)]
36
+ state = {"x": {"last_seen": 1000.0}}
37
+ results = check_jobs(jobs, state, now=1000.0 + 3600)
38
+ self.assertEqual(results[0]["status"], OK)
39
+
40
+ def test_a_job_that_never_pinged_is_never_seen_not_overdue(self):
41
+ """A different status on purpose: 'stopped running' and 'never
42
+ started' are different facts an operator needs to act on
43
+ differently, and collapsing them into one status would hide that."""
44
+ jobs = [_job("weekly-report", "8d", 8 * 86400)]
45
+ results = check_jobs(jobs, state={}, now=1000.0)
46
+ self.assertEqual(results[0]["status"], NEVER_SEEN)
47
+ self.assertIsNone(results[0]["last_seen"])
48
+
49
+ def test_multiple_jobs_are_each_evaluated_independently(self):
50
+ jobs = [
51
+ _job("a", "1h", 3600),
52
+ _job("b", "1h", 3600),
53
+ _job("c", "1h", 3600),
54
+ ]
55
+ state = {
56
+ "a": {"last_seen": 1000.0}, # will be ok
57
+ "b": {"last_seen": 1000.0 - 10000}, # will be overdue
58
+ # "c" never pinged
59
+ }
60
+ results = check_jobs(jobs, state, now=1000.0)
61
+ statuses = {r["name"]: r["status"] for r in results}
62
+ self.assertEqual(statuses, {"a": OK, "b": OVERDUE, "c": NEVER_SEEN})
63
+
64
+
65
+ if __name__ == "__main__":
66
+ unittest.main()
@@ -0,0 +1,89 @@
1
+ """Run: python tests/test_cli.py
2
+
3
+ Exercises the real CLI entry point end to end -- real temp files, real
4
+ argv, real stdout capture.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import contextlib
9
+ import io
10
+ import json
11
+ import pathlib
12
+ import sys
13
+ import tempfile
14
+ import time
15
+ import unittest
16
+
17
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
18
+
19
+ from escrow.cli import main
20
+
21
+
22
+ class TestCli(unittest.TestCase):
23
+ def setUp(self):
24
+ self.tmp = tempfile.TemporaryDirectory()
25
+ self.dir = pathlib.Path(self.tmp.name)
26
+ self.config = self.dir / "escrow.yaml"
27
+ self.state = self.dir / "state.json"
28
+
29
+ def tearDown(self):
30
+ self.tmp.cleanup()
31
+
32
+ def test_ping_then_check_reads_ok(self):
33
+ self.config.write_text("jobs:\n - name: nightly-backup\n interval: 26h\n")
34
+ code = main(["ping", "nightly-backup", "--state", str(self.state)])
35
+ self.assertEqual(code, 0)
36
+
37
+ buf = io.StringIO()
38
+ with contextlib.redirect_stdout(buf):
39
+ code = main(["check", str(self.config), "--state", str(self.state)])
40
+ self.assertEqual(code, 0)
41
+ self.assertIn("[OK]", buf.getvalue())
42
+ self.assertIn("1/1 ok", buf.getvalue())
43
+
44
+ def test_never_pinged_job_fails_check(self):
45
+ self.config.write_text("jobs:\n - name: weekly-report\n interval: 8d\n")
46
+ buf = io.StringIO()
47
+ with contextlib.redirect_stdout(buf):
48
+ code = main(["check", str(self.config), "--state", str(self.state)])
49
+ self.assertEqual(code, 1)
50
+ self.assertIn("[??]", buf.getvalue())
51
+ self.assertIn("need attention", buf.getvalue())
52
+
53
+ def test_overdue_job_fails_check(self):
54
+ self.config.write_text("jobs:\n - name: nightly-backup\n interval: 1h\n")
55
+ self.state.write_text(json.dumps({"nightly-backup": {"last_seen": time.time() - 7200}}))
56
+ buf = io.StringIO()
57
+ with contextlib.redirect_stdout(buf):
58
+ code = main(["check", str(self.config), "--state", str(self.state)])
59
+ self.assertEqual(code, 1)
60
+ self.assertIn("[!!]", buf.getvalue())
61
+
62
+ def test_state_path_that_is_a_directory_is_a_clean_error_on_ping(self):
63
+ """Found by testing an actual misconfigured --state: recording a
64
+ ping into a path that's a directory used to raise IsADirectoryError
65
+ straight out of main() as a traceback."""
66
+ state_dir = self.dir / "a_directory"
67
+ state_dir.mkdir()
68
+ with self.assertRaises(SystemExit) as ctx:
69
+ main(["ping", "x", "--state", str(state_dir)])
70
+ self.assertIn("escrow:", str(ctx.exception))
71
+
72
+ def test_bad_config_is_a_clean_error_not_a_traceback(self):
73
+ self.config.write_text("not: a valid escrow config\n")
74
+ with self.assertRaises(SystemExit) as ctx:
75
+ main(["check", str(self.config), "--state", str(self.state)])
76
+ self.assertIn("escrow:", str(ctx.exception))
77
+
78
+ def test_json_flag_prints_the_full_report(self):
79
+ self.config.write_text("jobs:\n - name: x\n interval: 1h\n")
80
+ main(["ping", "x", "--state", str(self.state)])
81
+ buf = io.StringIO()
82
+ with contextlib.redirect_stdout(buf):
83
+ main(["check", str(self.config), "--state", str(self.state), "--json"])
84
+ report = json.loads(buf.getvalue())
85
+ self.assertEqual(report[0]["status"], "ok")
86
+
87
+
88
+ if __name__ == "__main__":
89
+ unittest.main()
@@ -0,0 +1,94 @@
1
+ """Run: python tests/test_config.py"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
10
+
11
+ from escrow.config import ConfigError, load_config
12
+
13
+
14
+ class TestLoadConfig(unittest.TestCase):
15
+ def setUp(self):
16
+ self.tmp = tempfile.TemporaryDirectory()
17
+ self.path = pathlib.Path(self.tmp.name) / "escrow.yaml"
18
+
19
+ def tearDown(self):
20
+ self.tmp.cleanup()
21
+
22
+ def _write(self, text: str) -> str:
23
+ self.path.write_text(text, encoding="utf-8")
24
+ return str(self.path)
25
+
26
+ def test_valid_config(self):
27
+ p = self._write("jobs:\n - name: nightly-backup\n interval: 26h\n")
28
+ jobs = load_config(p)
29
+ self.assertEqual(jobs, [{
30
+ "name": "nightly-backup", "interval": "26h", "interval_seconds": 26 * 3600,
31
+ }])
32
+
33
+ def test_multiple_jobs(self):
34
+ p = self._write(
35
+ "jobs:\n"
36
+ " - name: nightly-backup\n interval: 26h\n"
37
+ " - name: weekly-report\n interval: 8d\n"
38
+ )
39
+ jobs = load_config(p)
40
+ self.assertEqual([j["name"] for j in jobs], ["nightly-backup", "weekly-report"])
41
+
42
+ def test_missing_file_is_a_config_error_not_a_crash(self):
43
+ with self.assertRaises(ConfigError) as ctx:
44
+ load_config(str(self.path)) # never written
45
+ self.assertIn(str(self.path), str(ctx.exception))
46
+
47
+ def test_not_yaml_is_a_config_error(self):
48
+ p = self._write("not: valid: yaml: at: all:::")
49
+ with self.assertRaises(ConfigError):
50
+ load_config(p)
51
+
52
+ def test_missing_jobs_key_is_rejected(self):
53
+ p = self._write("something_else: true\n")
54
+ with self.assertRaises(ConfigError) as ctx:
55
+ load_config(p)
56
+ self.assertIn("jobs", str(ctx.exception))
57
+
58
+ def test_empty_jobs_list_is_rejected(self):
59
+ p = self._write("jobs: []\n")
60
+ with self.assertRaises(ConfigError):
61
+ load_config(p)
62
+
63
+ def test_job_missing_name_is_rejected(self):
64
+ p = self._write("jobs:\n - interval: 1h\n")
65
+ with self.assertRaises(ConfigError) as ctx:
66
+ load_config(p)
67
+ self.assertIn("name", str(ctx.exception))
68
+
69
+ def test_job_missing_interval_is_rejected(self):
70
+ p = self._write("jobs:\n - name: x\n")
71
+ with self.assertRaises(ConfigError) as ctx:
72
+ load_config(p)
73
+ self.assertIn("interval", str(ctx.exception))
74
+
75
+ def test_duplicate_job_names_are_rejected(self):
76
+ p = self._write("jobs:\n - name: x\n interval: 1h\n - name: x\n interval: 2h\n")
77
+ with self.assertRaises(ConfigError) as ctx:
78
+ load_config(p)
79
+ self.assertIn("duplicate", str(ctx.exception))
80
+
81
+ def test_invalid_interval_reports_which_job(self):
82
+ p = self._write("jobs:\n - name: x\n interval: not-a-duration\n")
83
+ with self.assertRaises(ConfigError) as ctx:
84
+ load_config(p)
85
+ self.assertIn("'x'", str(ctx.exception))
86
+
87
+ def test_top_level_list_instead_of_mapping_is_rejected(self):
88
+ p = self._write("- just\n- a\n- list\n")
89
+ with self.assertRaises(ConfigError):
90
+ load_config(p)
91
+
92
+
93
+ if __name__ == "__main__":
94
+ unittest.main()
@@ -0,0 +1,55 @@
1
+ """Run: python tests/test_duration.py"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import unittest
7
+
8
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
9
+
10
+ from escrow.duration import DurationError, parse_duration
11
+
12
+
13
+ class TestParseDuration(unittest.TestCase):
14
+ def test_seconds(self):
15
+ self.assertEqual(parse_duration("30s"), 30)
16
+
17
+ def test_minutes(self):
18
+ self.assertEqual(parse_duration("45m"), 45 * 60)
19
+
20
+ def test_hours(self):
21
+ self.assertEqual(parse_duration("26h"), 26 * 3600)
22
+
23
+ def test_days(self):
24
+ self.assertEqual(parse_duration("8d"), 8 * 86400)
25
+
26
+ def test_weeks(self):
27
+ self.assertEqual(parse_duration("2w"), 2 * 604800)
28
+
29
+ def test_decimal_value(self):
30
+ self.assertEqual(parse_duration("1.5h"), 1.5 * 3600)
31
+
32
+ def test_whitespace_is_stripped(self):
33
+ self.assertEqual(parse_duration(" 26h "), 26 * 3600)
34
+
35
+ def test_combined_units_are_rejected_with_a_clear_message(self):
36
+ with self.assertRaises(DurationError) as ctx:
37
+ parse_duration("1d12h")
38
+ self.assertIn("not a duration", str(ctx.exception))
39
+ self.assertIn("36h", str(ctx.exception)) # the suggested workaround
40
+
41
+ def test_missing_unit_is_rejected(self):
42
+ with self.assertRaises(DurationError):
43
+ parse_duration("26")
44
+
45
+ def test_unknown_unit_is_rejected(self):
46
+ with self.assertRaises(DurationError):
47
+ parse_duration("26x")
48
+
49
+ def test_empty_string_is_rejected(self):
50
+ with self.assertRaises(DurationError):
51
+ parse_duration("")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ unittest.main()
@@ -0,0 +1,67 @@
1
+ """Run: python tests/test_state.py"""
2
+ from __future__ import annotations
3
+
4
+ import pathlib
5
+ import sys
6
+ import tempfile
7
+ import unittest
8
+
9
+ sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
10
+
11
+ from escrow.state import load_state, record_ping, save_state
12
+
13
+
14
+ class TestState(unittest.TestCase):
15
+ def setUp(self):
16
+ self.tmp = tempfile.TemporaryDirectory()
17
+ self.path = str(pathlib.Path(self.tmp.name) / "state.json")
18
+
19
+ def tearDown(self):
20
+ self.tmp.cleanup()
21
+
22
+ def test_missing_state_file_is_empty_not_a_crash(self):
23
+ self.assertEqual(load_state(self.path), {})
24
+
25
+ def test_record_ping_creates_the_file(self):
26
+ record_ping(self.path, "nightly-backup", 1000.0)
27
+ state = load_state(self.path)
28
+ self.assertEqual(state["nightly-backup"]["last_seen"], 1000.0)
29
+
30
+ def test_second_ping_updates_the_same_job(self):
31
+ record_ping(self.path, "nightly-backup", 1000.0)
32
+ record_ping(self.path, "nightly-backup", 2000.0)
33
+ state = load_state(self.path)
34
+ self.assertEqual(len(state), 1)
35
+ self.assertEqual(state["nightly-backup"]["last_seen"], 2000.0)
36
+
37
+ def test_pinging_different_jobs_keeps_both(self):
38
+ record_ping(self.path, "a", 1000.0)
39
+ record_ping(self.path, "b", 2000.0)
40
+ state = load_state(self.path)
41
+ self.assertEqual(set(state), {"a", "b"})
42
+
43
+ def test_corrupted_state_file_reads_as_empty_not_a_crash(self):
44
+ pathlib.Path(self.path).write_text("not valid json {{{", encoding="utf-8")
45
+ self.assertEqual(load_state(self.path), {})
46
+
47
+ def test_state_path_that_is_a_directory_reads_as_empty_not_a_crash(self):
48
+ """Found by testing an actual misconfigured --state pointed at a
49
+ directory: load_state used to only catch JSONDecodeError, and
50
+ IsADirectoryError (also an OSError) escaped uncaught."""
51
+ dir_path = str(pathlib.Path(self.tmp.name) / "a_directory")
52
+ pathlib.Path(dir_path).mkdir()
53
+ self.assertEqual(load_state(dir_path), {})
54
+
55
+ def test_save_state_creates_parent_directories(self):
56
+ nested = str(pathlib.Path(self.tmp.name) / "a" / "b" / "state.json")
57
+ save_state(nested, {"x": {"last_seen": 1.0}})
58
+ self.assertEqual(load_state(nested), {"x": {"last_seen": 1.0}})
59
+
60
+ def test_no_leftover_temp_file_after_a_normal_save(self):
61
+ save_state(self.path, {"x": {"last_seen": 1.0}})
62
+ leftovers = list(pathlib.Path(self.tmp.name).glob(".escrow-*"))
63
+ self.assertEqual(leftovers, [])
64
+
65
+
66
+ if __name__ == "__main__":
67
+ unittest.main()