stack-guardian 0.1.0__py3-none-any.whl
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.
- stack_guardian/__init__.py +13 -0
- stack_guardian/__main__.py +6 -0
- stack_guardian/checks.d/10_core.py +174 -0
- stack_guardian/cli.py +295 -0
- stack_guardian/collect.py +206 -0
- stack_guardian/core.py +264 -0
- stack_guardian/finding.py +491 -0
- stack_guardian/fixes.py +287 -0
- stack_guardian/mcp_server.py +340 -0
- stack_guardian/multinode.py +152 -0
- stack_guardian/parsers.py +658 -0
- stack_guardian/serve.py +198 -0
- stack_guardian/state.py +266 -0
- stack_guardian-0.1.0.dist-info/METADATA +164 -0
- stack_guardian-0.1.0.dist-info/RECORD +19 -0
- stack_guardian-0.1.0.dist-info/WHEEL +5 -0
- stack_guardian-0.1.0.dist-info/entry_points.txt +4 -0
- stack_guardian-0.1.0.dist-info/licenses/LICENSE +21 -0
- stack_guardian-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""Core types for "stack-guardian", a doctor that health-checks an AI stack snapshot.
|
|
2
|
+
|
|
3
|
+
Pure and standalone: standard library only, no network, no persistent files.
|
|
4
|
+
|
|
5
|
+
Public contract:
|
|
6
|
+
SEVERITIES / LEVELS / CONFIDENCES
|
|
7
|
+
make_finding(code, severity, level, node, summary, evidence=None, try_=None,
|
|
8
|
+
confidence="LIKELY", fix=None) -> dict
|
|
9
|
+
run_check(check_id, fn, snapshot, ctx, node="") -> dict or None
|
|
10
|
+
worst(findings) -> str
|
|
11
|
+
exit_code(findings) -> int
|
|
12
|
+
|
|
13
|
+
CLI:
|
|
14
|
+
python sg_finding.py --worst PATH # last stdout line is worst(...); rc is exit_code(...)
|
|
15
|
+
python sg_finding.py --selftest # counted checks; rc 0 only if all pass
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import contextlib
|
|
20
|
+
import io
|
|
21
|
+
import json
|
|
22
|
+
import os
|
|
23
|
+
import shutil
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
|
|
27
|
+
SEVERITIES = ("ok", "partial", "missing", "crash", "n/a")
|
|
28
|
+
LEVELS = ("OK", "INFO", "WARN", "FAIL") # ascending badness
|
|
29
|
+
CONFIDENCES = ("VERIFIED", "LIKELY", "INSUFFICIENT")
|
|
30
|
+
|
|
31
|
+
# Severities whose level is forced, regardless of the caller-supplied level.
|
|
32
|
+
_FORCED_LEVELS = {"ok": "OK", "n/a": "INFO", "crash": "WARN"}
|
|
33
|
+
|
|
34
|
+
SUMMARY_MAX = 120
|
|
35
|
+
CRASH_EVIDENCE_MAX = 300
|
|
36
|
+
|
|
37
|
+
FINDING_KEYS = (
|
|
38
|
+
"code",
|
|
39
|
+
"severity",
|
|
40
|
+
"level",
|
|
41
|
+
"node",
|
|
42
|
+
"summary",
|
|
43
|
+
"evidence",
|
|
44
|
+
"try",
|
|
45
|
+
"confidence",
|
|
46
|
+
"fix",
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _valid_fix(fix):
|
|
51
|
+
"""True iff fix is a dict shaped {"action": str, "args": dict}."""
|
|
52
|
+
if not isinstance(fix, dict):
|
|
53
|
+
return False
|
|
54
|
+
if "action" not in fix or "args" not in fix:
|
|
55
|
+
return False
|
|
56
|
+
return isinstance(fix["action"], str) and isinstance(fix["args"], dict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def make_finding(
|
|
60
|
+
code,
|
|
61
|
+
severity,
|
|
62
|
+
level,
|
|
63
|
+
node,
|
|
64
|
+
summary,
|
|
65
|
+
evidence=None,
|
|
66
|
+
try_=None,
|
|
67
|
+
confidence="LIKELY",
|
|
68
|
+
fix=None,
|
|
69
|
+
):
|
|
70
|
+
"""Build a finding dict with exactly the FINDING_KEYS keys.
|
|
71
|
+
|
|
72
|
+
Validation of severity/level/confidence happens before any level override,
|
|
73
|
+
so a bad enum always raises rather than being masked by the override.
|
|
74
|
+
"""
|
|
75
|
+
if severity not in SEVERITIES:
|
|
76
|
+
raise ValueError("unknown severity: %r" % (severity,))
|
|
77
|
+
if level not in LEVELS:
|
|
78
|
+
raise ValueError("unknown level: %r" % (level,))
|
|
79
|
+
if confidence not in CONFIDENCES:
|
|
80
|
+
raise ValueError("unknown confidence: %r" % (confidence,))
|
|
81
|
+
if fix is not None and not _valid_fix(fix):
|
|
82
|
+
raise ValueError("fix must be None or {'action': str, 'args': dict}")
|
|
83
|
+
|
|
84
|
+
effective = _FORCED_LEVELS.get(severity, level)
|
|
85
|
+
return {
|
|
86
|
+
"code": str(code),
|
|
87
|
+
"severity": severity,
|
|
88
|
+
"level": effective,
|
|
89
|
+
"node": str(node),
|
|
90
|
+
"summary": str(summary)[:SUMMARY_MAX],
|
|
91
|
+
"evidence": [str(x) for x in (evidence or [])],
|
|
92
|
+
"try": [str(x) for x in (try_ or [])],
|
|
93
|
+
"confidence": confidence,
|
|
94
|
+
"fix": None if fix is None else dict(fix),
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def run_check(check_id, fn, snapshot, ctx, node=""):
|
|
99
|
+
"""Run fn(snapshot, ctx) (a finding, a list of findings, or None); convert any Exception into a crash finding.
|
|
100
|
+
|
|
101
|
+
A buggy check never takes down the run and never reports FAIL: the crash
|
|
102
|
+
finding is severity "crash", which forces level "WARN".
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
return fn(snapshot, ctx)
|
|
106
|
+
except Exception as exc: # noqa: BLE001 - a broken check must not abort the run
|
|
107
|
+
# Coerce eagerly: reporting the crash must not raise a second time, or a
|
|
108
|
+
# non-str check_id would take down the run the handler exists to protect.
|
|
109
|
+
label = "%s" % (check_id,)
|
|
110
|
+
return make_finding(
|
|
111
|
+
"check.crashed",
|
|
112
|
+
"crash",
|
|
113
|
+
"WARN",
|
|
114
|
+
node,
|
|
115
|
+
"check %s raised %s" % (label, type(exc).__name__),
|
|
116
|
+
evidence=[("%s: %s" % (type(exc).__name__, exc))[:CRASH_EVIDENCE_MAX]],
|
|
117
|
+
try_=["fix or disable the check " + label],
|
|
118
|
+
confidence="VERIFIED",
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def worst(findings):
|
|
123
|
+
"""Highest LEVELS entry among the findings' "level" values; "OK" if none."""
|
|
124
|
+
best = "OK"
|
|
125
|
+
rank = LEVELS.index(best)
|
|
126
|
+
for finding in findings or ():
|
|
127
|
+
level = finding.get("level") if isinstance(finding, dict) else None
|
|
128
|
+
if level in LEVELS and LEVELS.index(level) > rank:
|
|
129
|
+
best = level
|
|
130
|
+
rank = LEVELS.index(level)
|
|
131
|
+
return best
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def exit_code(findings):
|
|
135
|
+
"""2 for FAIL, 1 for WARN, else 0."""
|
|
136
|
+
top = worst(findings)
|
|
137
|
+
if top == "FAIL":
|
|
138
|
+
return 2
|
|
139
|
+
if top == "WARN":
|
|
140
|
+
return 1
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def load_findings(path):
|
|
145
|
+
"""Read a JSON file holding a list of findings (None entries allowed)."""
|
|
146
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
147
|
+
data = json.load(handle)
|
|
148
|
+
if not isinstance(data, list):
|
|
149
|
+
raise ValueError("%s must hold a JSON list of findings" % (path,))
|
|
150
|
+
return data
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def main(argv=None):
|
|
154
|
+
"""CLI entry point; returns the process exit code."""
|
|
155
|
+
parser = argparse.ArgumentParser(
|
|
156
|
+
prog="sg_finding", description="Finding helpers for stack-guardian."
|
|
157
|
+
)
|
|
158
|
+
parser.add_argument("--worst", metavar="PATH", help="JSON file holding findings")
|
|
159
|
+
parser.add_argument(
|
|
160
|
+
"--selftest", action="store_true", help="run the in-file selftest"
|
|
161
|
+
)
|
|
162
|
+
args = parser.parse_args(argv)
|
|
163
|
+
|
|
164
|
+
if args.selftest:
|
|
165
|
+
return selftest()
|
|
166
|
+
if args.worst:
|
|
167
|
+
try:
|
|
168
|
+
findings = load_findings(args.worst)
|
|
169
|
+
except (OSError, ValueError) as exc:
|
|
170
|
+
sys.stderr.write("sg_finding: cannot read findings: %s\n" % (exc,))
|
|
171
|
+
return 2
|
|
172
|
+
top = worst(findings)
|
|
173
|
+
sys.stdout.write(top + "\n")
|
|
174
|
+
return exit_code(findings)
|
|
175
|
+
parser.error("one of --worst PATH or --selftest is required")
|
|
176
|
+
return 1 # unreachable; parser.error raises SystemExit
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------
|
|
180
|
+
# selftest
|
|
181
|
+
# --------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
def _expect_value_error(fn, *args, **kwargs):
|
|
184
|
+
try:
|
|
185
|
+
fn(*args, **kwargs)
|
|
186
|
+
except ValueError:
|
|
187
|
+
return True
|
|
188
|
+
except Exception: # a different exception type is still a contract failure
|
|
189
|
+
return False
|
|
190
|
+
return False
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _raising_check(_snapshot, _ctx):
|
|
194
|
+
raise ValueError("boom")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _raising_long_check(_snapshot, _ctx):
|
|
198
|
+
raise RuntimeError("x" * 500)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def check_constants():
|
|
202
|
+
return (
|
|
203
|
+
SEVERITIES == ("ok", "partial", "missing", "crash", "n/a")
|
|
204
|
+
and LEVELS == ("OK", "INFO", "WARN", "FAIL")
|
|
205
|
+
and CONFIDENCES == ("VERIFIED", "LIKELY", "INSUFFICIENT")
|
|
206
|
+
and LEVELS.index("OK") < LEVELS.index("INFO") < LEVELS.index("WARN") < LEVELS.index("FAIL")
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_finding_keys():
|
|
211
|
+
finding = make_finding("c", "partial", "WARN", "n1", "s")
|
|
212
|
+
keys = set(finding)
|
|
213
|
+
return (
|
|
214
|
+
len(keys) == len(FINDING_KEYS)
|
|
215
|
+
and keys == set(FINDING_KEYS)
|
|
216
|
+
and tuple(finding) == FINDING_KEYS
|
|
217
|
+
and isinstance(finding["evidence"], list)
|
|
218
|
+
and isinstance(finding["try"], list)
|
|
219
|
+
and finding["fix"] is None
|
|
220
|
+
and finding["confidence"] == "LIKELY"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def check_level_override():
|
|
225
|
+
pairs = (
|
|
226
|
+
("ok", "FAIL", "OK"),
|
|
227
|
+
("n/a", "FAIL", "INFO"),
|
|
228
|
+
("crash", "FAIL", "WARN"),
|
|
229
|
+
("partial", "WARN", "WARN"),
|
|
230
|
+
("partial", "FAIL", "FAIL"),
|
|
231
|
+
("missing", "FAIL", "FAIL"),
|
|
232
|
+
("missing", "INFO", "INFO"),
|
|
233
|
+
)
|
|
234
|
+
for severity, given, expected in pairs:
|
|
235
|
+
got = make_finding("c", severity, given, "n", "s")["level"]
|
|
236
|
+
if got != expected:
|
|
237
|
+
return False
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def check_text_coercion():
|
|
242
|
+
long_summary = "y" * 400
|
|
243
|
+
finding = make_finding(
|
|
244
|
+
"c",
|
|
245
|
+
"missing",
|
|
246
|
+
"WARN",
|
|
247
|
+
"node-1",
|
|
248
|
+
long_summary,
|
|
249
|
+
evidence=[1, None, "e"],
|
|
250
|
+
try_=[2.5, "z"],
|
|
251
|
+
)
|
|
252
|
+
coerced = make_finding("c", "ok", "OK", 7, 12345)
|
|
253
|
+
return (
|
|
254
|
+
len(finding["summary"]) == SUMMARY_MAX
|
|
255
|
+
and finding["summary"] == "y" * SUMMARY_MAX
|
|
256
|
+
and finding["summary"] == str(long_summary)[:SUMMARY_MAX]
|
|
257
|
+
and finding["evidence"] == ["1", "None", "e"]
|
|
258
|
+
and finding["try"] == ["2.5", "z"]
|
|
259
|
+
and coerced["summary"] == "12345"
|
|
260
|
+
and coerced["node"] == "7"
|
|
261
|
+
and coerced["code"] == "c"
|
|
262
|
+
and make_finding(99, "ok", "OK", "n", None)["code"] == "99"
|
|
263
|
+
and make_finding("c", "ok", "OK", "n", None)["summary"] == "None"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def check_bad_enum_raises():
|
|
268
|
+
return (
|
|
269
|
+
_expect_value_error(make_finding, "c", "nope", "WARN", "n", "s")
|
|
270
|
+
and _expect_value_error(make_finding, "c", "ok", "nope", "n", "s")
|
|
271
|
+
and _expect_value_error(
|
|
272
|
+
make_finding, "c", "ok", "OK", "n", "s", confidence="MAYBE"
|
|
273
|
+
)
|
|
274
|
+
and _expect_value_error(make_finding, "c", "warn", "OK", "n", "s")
|
|
275
|
+
and _expect_value_error(make_finding, "c", "OK", "OK", "n", "s")
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def check_enum_checked_before_override():
|
|
280
|
+
# A bad level must raise even when severity would have overridden it.
|
|
281
|
+
ok = _expect_value_error(make_finding, "c", "ok", "NOPE", "n", "s")
|
|
282
|
+
bad_sev = _expect_value_error(make_finding, "c", "NOPE", "OK", "n", "s")
|
|
283
|
+
return ok and bad_sev and SEVERITIES[0] == "ok"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_fix_validation():
|
|
287
|
+
supplied = {"action": "install", "args": {"pkg": "x"}}
|
|
288
|
+
good = make_finding("c", "partial", "WARN", "n", "s", fix=supplied)
|
|
289
|
+
bad_values = (
|
|
290
|
+
"install",
|
|
291
|
+
["install", {}],
|
|
292
|
+
{"action": "install"},
|
|
293
|
+
{"args": {}},
|
|
294
|
+
{"action": 1, "args": {}},
|
|
295
|
+
{"action": "install", "args": []},
|
|
296
|
+
{},
|
|
297
|
+
)
|
|
298
|
+
return (
|
|
299
|
+
good["fix"] == {"action": "install", "args": {"pkg": "x"}}
|
|
300
|
+
and good["fix"] is not supplied
|
|
301
|
+
and all(_expect_value_error(make_finding, "c", "ok", "OK", "n", "s", fix=b)
|
|
302
|
+
for b in bad_values)
|
|
303
|
+
and make_finding("c", "ok", "OK", "n", "s", fix=None)["fix"] is None
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def check_run_check_passthrough():
|
|
308
|
+
snapshot = {"nodes": 1}
|
|
309
|
+
ctx = {"deep": True}
|
|
310
|
+
seen = []
|
|
311
|
+
|
|
312
|
+
def ok_check(snap, c):
|
|
313
|
+
seen.append((snap, c))
|
|
314
|
+
return make_finding("ok", "ok", "OK", "", "fine")
|
|
315
|
+
|
|
316
|
+
def none_check(snap, c):
|
|
317
|
+
seen.append((snap, c))
|
|
318
|
+
return None
|
|
319
|
+
|
|
320
|
+
finding = run_check("ok", ok_check, snapshot, ctx)
|
|
321
|
+
passed_none = run_check("none", none_check, snapshot, ctx)
|
|
322
|
+
return (
|
|
323
|
+
isinstance(finding, dict)
|
|
324
|
+
and finding["code"] == "ok"
|
|
325
|
+
and finding["node"] == ""
|
|
326
|
+
and passed_none is None
|
|
327
|
+
and len(seen) == 2
|
|
328
|
+
and all(s is snapshot and c is ctx for s, c in seen)
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def check_run_check_crash():
|
|
333
|
+
finding = run_check("c1", _raising_check, {}, {}, node="nodeA")
|
|
334
|
+
evidence = "%s: %s" % ("ValueError", "boom")
|
|
335
|
+
return (
|
|
336
|
+
finding["code"] == "check.crashed"
|
|
337
|
+
and finding["severity"] == "crash"
|
|
338
|
+
and finding["level"] == "WARN"
|
|
339
|
+
and finding["node"] == "nodeA"
|
|
340
|
+
and finding["summary"] == "check c1 raised ValueError"
|
|
341
|
+
and finding["evidence"] == [evidence]
|
|
342
|
+
and finding["try"] == ["fix or disable the check c1"]
|
|
343
|
+
and finding["confidence"] == "VERIFIED"
|
|
344
|
+
and finding["fix"] is None
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def check_crash_never_fail_and_truncates():
|
|
349
|
+
finding = run_check("c2", _raising_long_check, {}, {})
|
|
350
|
+
msg = "%s: %s" % ("RuntimeError", "x" * 500)
|
|
351
|
+
return (
|
|
352
|
+
finding["level"] == "WARN"
|
|
353
|
+
and finding["level"] != "FAIL"
|
|
354
|
+
and exit_code([finding]) == 1
|
|
355
|
+
and len(finding["evidence"]) == 1
|
|
356
|
+
and len(finding["evidence"][0]) == CRASH_EVIDENCE_MAX
|
|
357
|
+
and finding["evidence"][0] == msg[:CRASH_EVIDENCE_MAX]
|
|
358
|
+
and finding["node"] == ""
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def check_run_check_survives_odd_ids():
|
|
363
|
+
# Reporting the crash must never raise, whatever the id/node look like.
|
|
364
|
+
findings = [
|
|
365
|
+
run_check(7, _raising_check, {}, {}),
|
|
366
|
+
run_check(None, _raising_check, {}, {}, node=None),
|
|
367
|
+
run_check(("a", 1), _raising_check, {}, {}, node=("n", 2)),
|
|
368
|
+
run_check("x", _raising_check, {}, {}, node=""),
|
|
369
|
+
]
|
|
370
|
+
return (
|
|
371
|
+
len(findings) == 4
|
|
372
|
+
and all(isinstance(f, dict) for f in findings)
|
|
373
|
+
and all(f["code"] == "check.crashed" for f in findings)
|
|
374
|
+
and all(f["level"] == "WARN" for f in findings)
|
|
375
|
+
and findings[0]["try"] == ["fix or disable the check 7"]
|
|
376
|
+
and findings[0]["summary"] == "check 7 raised ValueError"
|
|
377
|
+
and findings[1]["node"] == "None"
|
|
378
|
+
and findings[2]["node"] == "('n', 2)"
|
|
379
|
+
and all(exit_code([f]) == 1 for f in findings)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def check_worst_basics():
|
|
384
|
+
ok = make_finding("a", "ok", "OK", "", "s")
|
|
385
|
+
warn = make_finding("b", "partial", "WARN", "", "s")
|
|
386
|
+
fail = make_finding("c", "missing", "FAIL", "", "s")
|
|
387
|
+
return (
|
|
388
|
+
worst([ok]) == "OK"
|
|
389
|
+
and worst([ok, warn]) == "WARN"
|
|
390
|
+
and worst([warn, ok, fail]) == "FAIL"
|
|
391
|
+
and worst([fail, warn]) == "FAIL"
|
|
392
|
+
and worst([ok, ok]) == "OK"
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def check_worst_ignores_none():
|
|
397
|
+
no_level = make_finding("a", "partial", "WARN", "", "s")
|
|
398
|
+
no_level["level"] = None
|
|
399
|
+
return (
|
|
400
|
+
worst([]) == "OK"
|
|
401
|
+
and worst(None) == "OK"
|
|
402
|
+
and worst([None, None]) == "OK"
|
|
403
|
+
and worst([None, no_level]) == "OK"
|
|
404
|
+
and worst([None, no_level, {"level": "nonsense"}, "junk", make_finding("d", "missing", "FAIL", "", "s")]) == "FAIL"
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def check_exit_codes():
|
|
409
|
+
ok = make_finding("a", "ok", "OK", "", "s")
|
|
410
|
+
info = make_finding("b", "n/a", "FAIL", "", "s")
|
|
411
|
+
warn = make_finding("c", "partial", "WARN", "", "s")
|
|
412
|
+
fail = make_finding("d", "missing", "FAIL", "", "s")
|
|
413
|
+
return (
|
|
414
|
+
exit_code([]) == 0
|
|
415
|
+
and exit_code([ok, info]) == 0
|
|
416
|
+
and exit_code([ok, warn]) == 1
|
|
417
|
+
and exit_code([warn, fail]) == 2
|
|
418
|
+
and exit_code([None, ok]) == 0
|
|
419
|
+
and exit_code([None, warn]) == 1
|
|
420
|
+
)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def check_cli_roundtrip():
|
|
424
|
+
findings = [
|
|
425
|
+
make_finding("a", "ok", "OK", "", "healthy"),
|
|
426
|
+
None,
|
|
427
|
+
make_finding("b", "partial", "WARN", "n", "degraded"),
|
|
428
|
+
]
|
|
429
|
+
workdir = tempfile.mkdtemp()
|
|
430
|
+
try:
|
|
431
|
+
path = os.path.join(workdir, "findings.json")
|
|
432
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
433
|
+
json.dump(findings, handle)
|
|
434
|
+
buf = io.StringIO()
|
|
435
|
+
with contextlib.redirect_stdout(buf):
|
|
436
|
+
code = main(["--worst", path])
|
|
437
|
+
lines = [ln for ln in buf.getvalue().splitlines() if ln.strip()]
|
|
438
|
+
return (
|
|
439
|
+
os.path.exists(path)
|
|
440
|
+
and code == 1
|
|
441
|
+
and len(lines) >= 1
|
|
442
|
+
and lines[-1] == worst(findings)
|
|
443
|
+
and lines[-1] == "WARN"
|
|
444
|
+
)
|
|
445
|
+
finally:
|
|
446
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
SELFTEST_CHECKS = [
|
|
450
|
+
("constants", check_constants),
|
|
451
|
+
("finding_keys", check_finding_keys),
|
|
452
|
+
("level_override", check_level_override),
|
|
453
|
+
("text_coercion", check_text_coercion),
|
|
454
|
+
("bad_enum_raises", check_bad_enum_raises),
|
|
455
|
+
("enum_checked_before_override", check_enum_checked_before_override),
|
|
456
|
+
("fix_validation", check_fix_validation),
|
|
457
|
+
("run_check_passthrough", check_run_check_passthrough),
|
|
458
|
+
("run_check_crash", check_run_check_crash),
|
|
459
|
+
("crash_never_fail_and_truncates", check_crash_never_fail_and_truncates),
|
|
460
|
+
("run_check_survives_odd_ids", check_run_check_survives_odd_ids),
|
|
461
|
+
("worst_basics", check_worst_basics),
|
|
462
|
+
("worst_ignores_none", check_worst_ignores_none),
|
|
463
|
+
("exit_codes", check_exit_codes),
|
|
464
|
+
("cli_roundtrip", check_cli_roundtrip),
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def selftest():
|
|
469
|
+
"""Run every named check; print the counted summary; return an exit code."""
|
|
470
|
+
outcomes = []
|
|
471
|
+
for _name, check in SELFTEST_CHECKS:
|
|
472
|
+
try:
|
|
473
|
+
outcomes.append(bool(check()))
|
|
474
|
+
except Exception as exc: # a broken check is a failure, not a crash
|
|
475
|
+
sys.stderr.write("check %s errored: %s\n" % (_name, exc))
|
|
476
|
+
outcomes.append(False)
|
|
477
|
+
total = len(SELFTEST_CHECKS)
|
|
478
|
+
passed = 0
|
|
479
|
+
for outcome in outcomes:
|
|
480
|
+
if outcome:
|
|
481
|
+
passed += 1
|
|
482
|
+
failed = total - passed
|
|
483
|
+
sys.stdout.write(
|
|
484
|
+
"sg_finding selftest: %d checks, %d passed, %d failed\n"
|
|
485
|
+
% (total, passed, failed)
|
|
486
|
+
)
|
|
487
|
+
return 0 if failed == 0 and total > 0 else 1
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
if __name__ == "__main__":
|
|
491
|
+
sys.exit(main(sys.argv[1:]))
|