wtftools 0.0.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.
- wtftools/__init__.py +55 -0
- wtftools/__main__.py +10 -0
- wtftools/audit.py +809 -0
- wtftools/colors.py +111 -0
- wtftools/config.py +249 -0
- wtftools/cron.py +388 -0
- wtftools/events.py +220 -0
- wtftools/explain.py +290 -0
- wtftools/info.py +90 -0
- wtftools/llm.py +129 -0
- wtftools/main.py +1328 -0
- wtftools/snapshot.py +203 -0
- wtftools/sysinfo.py +1608 -0
- wtftools-0.0.0.data/data/share/bash-completion/completions/wtf.bash-completion +134 -0
- wtftools-0.0.0.dist-info/METADATA +246 -0
- wtftools-0.0.0.dist-info/RECORD +20 -0
- wtftools-0.0.0.dist-info/WHEEL +5 -0
- wtftools-0.0.0.dist-info/entry_points.txt +3 -0
- wtftools-0.0.0.dist-info/licenses/LICENSE +21 -0
- wtftools-0.0.0.dist-info/top_level.txt +1 -0
wtftools/snapshot.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Snapshot + diff for wtftools — `wtfd-lite`.
|
|
4
|
+
|
|
5
|
+
`wtf audit --save` writes the current run to a JSON file in
|
|
6
|
+
`$XDG_CACHE_HOME/wtftools/snapshots/` (or `/var/lib/wtftools/snapshots/` when
|
|
7
|
+
running as root). `wtf history` and `wtf diff` consume them.
|
|
8
|
+
|
|
9
|
+
Snapshots are bounded — the oldest ones rotate out so the directory does not
|
|
10
|
+
grow forever.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
import traceback
|
|
18
|
+
from dataclasses import asdict
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
from wtftools.audit import CheckResult
|
|
23
|
+
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
DEFAULT_KEEP = 48
|
|
27
|
+
|
|
28
|
+
# Snapshot directory selection:
|
|
29
|
+
# 1. WTFTOOLS_SNAPSHOT_DIR env override (test seam + power-user knob)
|
|
30
|
+
# 2. /var/lib/wtftools/snapshots if running as root (system-wide history)
|
|
31
|
+
# 3. $XDG_CACHE_HOME/wtftools/snapshots else ~/.cache/wtftools/snapshots
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def default_snapshot_dir() -> str:
|
|
35
|
+
env_override = os.environ.get("WTFTOOLS_SNAPSHOT_DIR")
|
|
36
|
+
if env_override:
|
|
37
|
+
return env_override
|
|
38
|
+
try:
|
|
39
|
+
if os.geteuid() == 0:
|
|
40
|
+
return "/var/lib/wtftools/snapshots"
|
|
41
|
+
except (AttributeError, OSError):
|
|
42
|
+
pass
|
|
43
|
+
base = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
|
|
44
|
+
return os.path.join(base, "wtftools", "snapshots")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ensure_dir(path: str) -> bool:
|
|
48
|
+
"""Create the snapshot dir if missing. Returns True on success."""
|
|
49
|
+
try:
|
|
50
|
+
os.makedirs(path, exist_ok=True)
|
|
51
|
+
return True
|
|
52
|
+
except OSError as exc:
|
|
53
|
+
logger.warning(f"cannot create snapshot dir {path}: {exc}")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save_snapshot(results: List[CheckResult], host: str, directory: Optional[str] = None) -> Optional[str]:
|
|
58
|
+
"""Persist a snapshot. Returns the path written, or None on failure."""
|
|
59
|
+
directory = directory or default_snapshot_dir()
|
|
60
|
+
if not ensure_dir(directory):
|
|
61
|
+
return None
|
|
62
|
+
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
|
63
|
+
path = os.path.join(directory, f"{ts}.json")
|
|
64
|
+
payload = {
|
|
65
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
66
|
+
"epoch": int(time.time()),
|
|
67
|
+
"host": host,
|
|
68
|
+
"results": [asdict(r) for r in results],
|
|
69
|
+
}
|
|
70
|
+
try:
|
|
71
|
+
with open(path, "w", encoding="utf-8") as f:
|
|
72
|
+
json.dump(payload, f, indent=2, default=str)
|
|
73
|
+
except OSError as exc:
|
|
74
|
+
logger.warning(f"cannot write snapshot {path}: {exc}")
|
|
75
|
+
return None
|
|
76
|
+
_rotate(directory)
|
|
77
|
+
return path
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _rotate(directory: str, keep: int = DEFAULT_KEEP) -> None:
|
|
81
|
+
"""Delete the oldest snapshots above `keep`."""
|
|
82
|
+
try:
|
|
83
|
+
files = sorted(
|
|
84
|
+
(f for f in os.listdir(directory) if f.endswith(".json")),
|
|
85
|
+
)
|
|
86
|
+
except OSError:
|
|
87
|
+
return
|
|
88
|
+
if len(files) <= keep:
|
|
89
|
+
return
|
|
90
|
+
for old in files[: len(files) - keep]:
|
|
91
|
+
try:
|
|
92
|
+
os.unlink(os.path.join(directory, old))
|
|
93
|
+
except OSError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def list_snapshots(directory: Optional[str] = None) -> List[str]:
|
|
98
|
+
"""Return snapshot paths sorted oldest → newest."""
|
|
99
|
+
directory = directory or default_snapshot_dir()
|
|
100
|
+
if not os.path.isdir(directory):
|
|
101
|
+
return []
|
|
102
|
+
try:
|
|
103
|
+
return sorted(os.path.join(directory, f) for f in os.listdir(directory) if f.endswith(".json"))
|
|
104
|
+
except OSError:
|
|
105
|
+
return []
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def load_snapshot(path: str) -> Optional[Dict[str, Any]]:
|
|
109
|
+
"""Load a snapshot file. Returns None on parse error."""
|
|
110
|
+
try:
|
|
111
|
+
with open(path, encoding="utf-8") as f:
|
|
112
|
+
return json.load(f)
|
|
113
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
114
|
+
logger.warning(f"cannot load snapshot {path}: {type(exc).__name__}: {exc}\n" f"{traceback.format_exc()}")
|
|
115
|
+
return None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def latest_snapshot(directory: Optional[str] = None) -> Optional[Dict[str, Any]]:
|
|
119
|
+
paths = list_snapshots(directory)
|
|
120
|
+
if not paths:
|
|
121
|
+
return None
|
|
122
|
+
return load_snapshot(paths[-1])
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def diff_snapshots(old: Dict[str, Any], new_results: List[CheckResult]) -> List[Dict[str, Any]]:
|
|
126
|
+
"""Compare an old snapshot to current results.
|
|
127
|
+
|
|
128
|
+
Returns a list of change events, each:
|
|
129
|
+
{"name": str, "kind": "regression"|"recovery"|"worsened"|"improved"
|
|
130
|
+
|"new"|"removed"|"unchanged",
|
|
131
|
+
"old_status": Optional[str], "new_status": Optional[str],
|
|
132
|
+
"old_message": Optional[str], "new_message": Optional[str]}
|
|
133
|
+
Only non-unchanged events are returned.
|
|
134
|
+
"""
|
|
135
|
+
order = ["ok", "skip", "warn", "fail"]
|
|
136
|
+
|
|
137
|
+
def severity(s: str) -> int:
|
|
138
|
+
try:
|
|
139
|
+
return order.index(s)
|
|
140
|
+
except ValueError:
|
|
141
|
+
return 1 # unknown → treat as skip-ish
|
|
142
|
+
|
|
143
|
+
old_map: Dict[str, Dict[str, str]] = {}
|
|
144
|
+
for r in old.get("results", []) or []:
|
|
145
|
+
old_map[r["name"]] = {"status": r["status"], "message": r.get("message", "")}
|
|
146
|
+
|
|
147
|
+
new_map: Dict[str, Dict[str, str]] = {}
|
|
148
|
+
for r in new_results:
|
|
149
|
+
new_map[r.name] = {"status": r.status, "message": r.message}
|
|
150
|
+
|
|
151
|
+
events: List[Dict[str, Any]] = []
|
|
152
|
+
for name, new in new_map.items():
|
|
153
|
+
if name not in old_map:
|
|
154
|
+
events.append(
|
|
155
|
+
{
|
|
156
|
+
"name": name,
|
|
157
|
+
"kind": "new",
|
|
158
|
+
"old_status": None,
|
|
159
|
+
"new_status": new["status"],
|
|
160
|
+
"old_message": None,
|
|
161
|
+
"new_message": new["message"],
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
continue
|
|
165
|
+
old = old_map[name]
|
|
166
|
+
if old["status"] == new["status"]:
|
|
167
|
+
# Status unchanged. Surface only if message *meaningfully* changed
|
|
168
|
+
# — we skip these in diff to avoid noise from time-varying numbers.
|
|
169
|
+
continue
|
|
170
|
+
old_sev = severity(old["status"])
|
|
171
|
+
new_sev = severity(new["status"])
|
|
172
|
+
if new_sev > old_sev:
|
|
173
|
+
kind = "regression" if (new["status"] == "fail" and old["status"] in ("ok", "skip")) else "worsened"
|
|
174
|
+
else:
|
|
175
|
+
kind = "recovery" if (old["status"] == "fail" and new["status"] in ("ok", "skip")) else "improved"
|
|
176
|
+
events.append(
|
|
177
|
+
{
|
|
178
|
+
"name": name,
|
|
179
|
+
"kind": kind,
|
|
180
|
+
"old_status": old["status"],
|
|
181
|
+
"new_status": new["status"],
|
|
182
|
+
"old_message": old["message"],
|
|
183
|
+
"new_message": new["message"],
|
|
184
|
+
}
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
for name, old in old_map.items():
|
|
188
|
+
if name not in new_map:
|
|
189
|
+
events.append(
|
|
190
|
+
{
|
|
191
|
+
"name": name,
|
|
192
|
+
"kind": "removed",
|
|
193
|
+
"old_status": old["status"],
|
|
194
|
+
"new_status": None,
|
|
195
|
+
"old_message": old["message"],
|
|
196
|
+
"new_message": None,
|
|
197
|
+
}
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Sort: regressions first, then worsened, then new, then improved, etc.
|
|
201
|
+
kind_order = {"regression": 0, "worsened": 1, "new": 2, "improved": 3, "recovery": 4, "removed": 5}
|
|
202
|
+
events.sort(key=lambda e: kind_order.get(e["kind"], 99))
|
|
203
|
+
return events
|