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/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