holt-cli 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.
- holt/__init__.py +0 -0
- holt/agent/__init__.py +0 -0
- holt/agent/entry.py +86 -0
- holt/agent/findings.py +49 -0
- holt/agent/landing.py +154 -0
- holt/agent/pipeline.py +244 -0
- holt/agent/progression.py +408 -0
- holt/agent/signals.py +220 -0
- holt/agent/stages.py +533 -0
- holt/agent/verdict.py +226 -0
- holt/agent/verify.py +140 -0
- holt/baseline.py +89 -0
- holt/baseline_matched.py +116 -0
- holt/cli.py +616 -0
- holt/discover.py +497 -0
- holt/evidence/__init__.py +3 -0
- holt/evidence/fixtures.py +154 -0
- holt/evidence/github_graphql.py +538 -0
- holt/evidence/provider.py +77 -0
- holt/evidence/redact.py +79 -0
- holt/issues.py +41 -0
- holt/model.py +516 -0
- holt/profile.py +126 -0
- holt/report.py +157 -0
- holt/tui/__init__.py +0 -0
- holt/tui/animation.py +84 -0
- holt/tui/app.py +294 -0
- holt/tui/clipboard.py +89 -0
- holt/tui/commands.py +134 -0
- holt/tui/discovery.py +305 -0
- holt/tui/env.py +49 -0
- holt/tui/events.py +245 -0
- holt/tui/mascot.py +121 -0
- holt/tui/models.py +590 -0
- holt/tui/observe.py +297 -0
- holt/tui/screens/__init__.py +35 -0
- holt/tui/screens/assessment.py +337 -0
- holt/tui/screens/confirm.py +62 -0
- holt/tui/screens/discover.py +444 -0
- holt/tui/screens/home.py +519 -0
- holt/tui/screens/inspector.py +106 -0
- holt/tui/screens/live.py +335 -0
- holt/tui/screens/models.py +393 -0
- holt/tui/screens/next_steps.py +425 -0
- holt/tui/screens/profile.py +129 -0
- holt/tui/session.py +711 -0
- holt/tui/store.py +458 -0
- holt/tui/theme.py +479 -0
- holt/tui/visual.py +33 -0
- holt/tui/widgets/__init__.py +0 -0
- holt/tui/widgets/candidates.py +78 -0
- holt/tui/widgets/claims.py +59 -0
- holt/tui/widgets/disclosure.py +121 -0
- holt/tui/widgets/evidence.py +121 -0
- holt/tui/widgets/masthead.py +122 -0
- holt/tui/widgets/recent.py +167 -0
- holt/tui/widgets/scrolling.py +38 -0
- holt/tui/widgets/stages.py +232 -0
- holt/types.py +48 -0
- holt_cli-0.1.0.dist-info/METADATA +198 -0
- holt_cli-0.1.0.dist-info/RECORD +65 -0
- holt_cli-0.1.0.dist-info/WHEEL +4 -0
- holt_cli-0.1.0.dist-info/entry_points.txt +2 -0
- holt_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
- holt_cli-0.1.0.dist-info/licenses/NOTICE +4 -0
holt/tui/store.py
ADDED
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Assessments that outlive the process.
|
|
2
|
+
|
|
3
|
+
Two jobs, one file on disk per assessment:
|
|
4
|
+
|
|
5
|
+
* **History.** The interface opens on what you have already looked at, because
|
|
6
|
+
the first question is almost never "assess something new" — it is "what did it
|
|
7
|
+
say about the thing I ran this morning".
|
|
8
|
+
* **Cache.** Assessing a repository costs a minute and real money. Asking for
|
|
9
|
+
one that was assessed four minutes ago should hand back that answer rather
|
|
10
|
+
than spend again, and should say plainly that is what it did.
|
|
11
|
+
|
|
12
|
+
Design rules that matter here:
|
|
13
|
+
|
|
14
|
+
* **Never lie about freshness.** A reused assessment is labelled with its age
|
|
15
|
+
everywhere it appears, and re-running is always one key away. A cache that
|
|
16
|
+
hides its age is indistinguishable from a stale answer.
|
|
17
|
+
* **A corrupt file is skipped, never fatal.** History is a convenience; losing
|
|
18
|
+
it must not stop you assessing something. Every read is defensive.
|
|
19
|
+
* **Writes are atomic.** Temp file then `os.replace`, so a process killed
|
|
20
|
+
mid-write leaves the previous entry intact rather than a truncated one.
|
|
21
|
+
* **The engine's types are converted here, not taught to serialise themselves.**
|
|
22
|
+
`holt.report` is frozen; this module knows how to write an `Assessment` down
|
|
23
|
+
and read one back, and that knowledge lives on this side of the boundary.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import dataclasses
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import tempfile
|
|
32
|
+
import time
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from datetime import datetime
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
|
|
38
|
+
from holt.evidence.fixtures import record_from_dict, record_to_dict
|
|
39
|
+
from holt.report import Assessment, Claim, EntryPoint, Verdict
|
|
40
|
+
from holt.tui import events as events_module
|
|
41
|
+
|
|
42
|
+
#: Bumped when the on-disk shape changes in a way older readers cannot handle.
|
|
43
|
+
#: Entries with a different version are ignored rather than guessed at.
|
|
44
|
+
SCHEMA_VERSION = 1
|
|
45
|
+
|
|
46
|
+
#: Where history lives. Outside `fixtures/`, outside the reproduction path, and
|
|
47
|
+
#: in `.gitignore` — this is a convenience for one machine, not project content.
|
|
48
|
+
DEFAULT_ROOT = Path(".holt/assessments")
|
|
49
|
+
|
|
50
|
+
#: How long an assessment is considered fresh enough to reuse without asking.
|
|
51
|
+
#: Ten minutes: long enough to cover re-opening the tool while still working on
|
|
52
|
+
#: the same repository, short enough that nobody mistakes it for live data.
|
|
53
|
+
DEFAULT_MAX_AGE_SECONDS = 10 * 60
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass(slots=True)
|
|
57
|
+
class Entry:
|
|
58
|
+
"""One stored assessment, plus how it was produced."""
|
|
59
|
+
|
|
60
|
+
repo: str
|
|
61
|
+
mode: str # replay | recorded | live
|
|
62
|
+
created_at: float
|
|
63
|
+
assessment: Assessment
|
|
64
|
+
contributor_days: int = 7
|
|
65
|
+
duration_seconds: float = 0.0
|
|
66
|
+
cost_usd: float = 0.0
|
|
67
|
+
#: The run's own event stream, so the trace survives the process that
|
|
68
|
+
#: produced it. Empty for an assessment stored before this was kept, which
|
|
69
|
+
#: the trace view says rather than showing an empty screen.
|
|
70
|
+
events: list[Any] = field(default_factory=list)
|
|
71
|
+
#: The records behind the ids this assessment cites, as the run saw them.
|
|
72
|
+
#: Not the whole crawl — see `Session.evidence_for_storage`. This is what
|
|
73
|
+
#: makes a reopened report checkable rather than something to take on
|
|
74
|
+
#: trust, including a live one, whose records exist nowhere else.
|
|
75
|
+
evidence: list[Any] = field(default_factory=list)
|
|
76
|
+
path: Path | None = None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def age_seconds(self) -> float:
|
|
80
|
+
# Clamped: a clock change or a file copied from another machine must not
|
|
81
|
+
# produce a negative age that renders as "in 3 minutes".
|
|
82
|
+
return max(0.0, time.time() - self.created_at)
|
|
83
|
+
|
|
84
|
+
def fresh(self, max_age: float = DEFAULT_MAX_AGE_SECONDS) -> bool:
|
|
85
|
+
return self.age_seconds <= max_age
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def key(self) -> tuple[str, str, int]:
|
|
89
|
+
"""What makes two assessments the same question.
|
|
90
|
+
|
|
91
|
+
The day budget is part of it because `verdict.py` reads it: the same
|
|
92
|
+
repository can be worth a fortnight and not worth an afternoon, and
|
|
93
|
+
serving one answer for the other would be wrong.
|
|
94
|
+
"""
|
|
95
|
+
return (self.repo, self.mode, self.contributor_days)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def describe_age(seconds: float) -> str:
|
|
99
|
+
"""Human, and never more precise than it is."""
|
|
100
|
+
seconds = max(0.0, seconds)
|
|
101
|
+
if seconds < 45:
|
|
102
|
+
return "just now"
|
|
103
|
+
minutes = seconds / 60
|
|
104
|
+
if minutes < 60:
|
|
105
|
+
n = round(minutes)
|
|
106
|
+
return f"{n} min ago" if n != 1 else "1 min ago"
|
|
107
|
+
hours = minutes / 60
|
|
108
|
+
if hours < 24:
|
|
109
|
+
n = round(hours)
|
|
110
|
+
return f"{n} hours ago" if n != 1 else "1 hour ago"
|
|
111
|
+
days = hours / 24
|
|
112
|
+
n = round(days)
|
|
113
|
+
return f"{n} days ago" if n != 1 else "1 day ago"
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ─── the run's trace ────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
#: The events kept on disk, by class name. Deliberately not all of them:
|
|
120
|
+
#: `ToolResponse` carries a whole model payload that nothing renders, and
|
|
121
|
+
#: `RunFinished` carries the `Assessment` this entry already holds. An event
|
|
122
|
+
#: not named here is dropped on the way out and ignored on the way back, which
|
|
123
|
+
#: is what lets this build read a file written by one that knows more events
|
|
124
|
+
#: than it does — and the reverse.
|
|
125
|
+
TRACE_EVENTS: dict[str, type] = {
|
|
126
|
+
cls.__name__: cls
|
|
127
|
+
for cls in (
|
|
128
|
+
events_module.RunStarted,
|
|
129
|
+
events_module.EvidenceLoaded,
|
|
130
|
+
events_module.StageStarted,
|
|
131
|
+
events_module.StageFinished,
|
|
132
|
+
events_module.FindingEmitted,
|
|
133
|
+
events_module.EvidenceResolved,
|
|
134
|
+
events_module.FindingDropped,
|
|
135
|
+
events_module.UsageUpdated,
|
|
136
|
+
events_module.Retry,
|
|
137
|
+
events_module.RunFailed,
|
|
138
|
+
events_module.RunCancelled,
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
#: Tuples on the event, lists in JSON.
|
|
143
|
+
_TUPLE_FIELDS = frozenset({"evidence_ids", "cited", "completed_stages"})
|
|
144
|
+
#: Datetimes on the event, ISO strings in JSON.
|
|
145
|
+
_TIME_FIELDS = frozenset({"cutoff"})
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _jsonable(value: Any) -> Any:
|
|
149
|
+
"""Anything, as something `json.dump` will accept.
|
|
150
|
+
|
|
151
|
+
A finding's `value` is whatever a model put in a field. One unserialisable
|
|
152
|
+
object in it must not take the whole write down — history is a convenience,
|
|
153
|
+
and a stringified value still says what the run reported.
|
|
154
|
+
"""
|
|
155
|
+
if value is None or isinstance(value, (str, int, float, bool)):
|
|
156
|
+
return value
|
|
157
|
+
if isinstance(value, (list, tuple)):
|
|
158
|
+
return [_jsonable(v) for v in value]
|
|
159
|
+
if isinstance(value, dict):
|
|
160
|
+
return {str(k): _jsonable(v) for k, v in value.items()}
|
|
161
|
+
return str(value)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def event_to_dict(event: Any) -> dict[str, Any] | None:
|
|
165
|
+
"""One event, or `None` if it is not one we keep."""
|
|
166
|
+
cls = TRACE_EVENTS.get(type(event).__name__)
|
|
167
|
+
if cls is None:
|
|
168
|
+
return None
|
|
169
|
+
out: dict[str, Any] = {"event": type(event).__name__}
|
|
170
|
+
for spec in dataclasses.fields(cls):
|
|
171
|
+
value = getattr(event, spec.name, None)
|
|
172
|
+
if spec.name in _TIME_FIELDS:
|
|
173
|
+
value = value.isoformat() if isinstance(value, datetime) else None
|
|
174
|
+
out[spec.name] = _jsonable(value)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def event_from_dict(raw: dict[str, Any]) -> Any | None:
|
|
179
|
+
"""Rebuild an event, or `None` if this build cannot make sense of it."""
|
|
180
|
+
if not isinstance(raw, dict):
|
|
181
|
+
return None
|
|
182
|
+
cls = TRACE_EVENTS.get(raw.get("event", ""))
|
|
183
|
+
if cls is None:
|
|
184
|
+
return None
|
|
185
|
+
kwargs: dict[str, Any] = {}
|
|
186
|
+
for spec in dataclasses.fields(cls):
|
|
187
|
+
if spec.name not in raw:
|
|
188
|
+
# Written by a build from before the field existed. The event class
|
|
189
|
+
# defaults it, which is exactly why the schema only ever adds.
|
|
190
|
+
continue
|
|
191
|
+
value = raw[spec.name]
|
|
192
|
+
if spec.name in _TIME_FIELDS:
|
|
193
|
+
try:
|
|
194
|
+
value = datetime.fromisoformat(value) if value else None
|
|
195
|
+
except (TypeError, ValueError):
|
|
196
|
+
value = None
|
|
197
|
+
elif spec.name in _TUPLE_FIELDS:
|
|
198
|
+
value = tuple(value or ())
|
|
199
|
+
kwargs[spec.name] = value
|
|
200
|
+
try:
|
|
201
|
+
return cls(**kwargs)
|
|
202
|
+
except (TypeError, ValueError):
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ─── the records behind the claims ──────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def record_or_none(raw: Any) -> Any | None:
|
|
210
|
+
"""One evidence record read back, or `None` if the file cannot supply one.
|
|
211
|
+
|
|
212
|
+
Defensive for the same reason every other read here is: a report whose
|
|
213
|
+
stored records are damaged is still a report worth opening, and the
|
|
214
|
+
inspector already has a sentence for an id it cannot look up.
|
|
215
|
+
"""
|
|
216
|
+
if not isinstance(raw, dict):
|
|
217
|
+
return None
|
|
218
|
+
try:
|
|
219
|
+
return record_from_dict(raw)
|
|
220
|
+
except (KeyError, TypeError, ValueError):
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def records_to_json(records: list[Any]) -> list[dict[str, Any]]:
|
|
225
|
+
"""Records on their way to disk, skipping any that will not serialise."""
|
|
226
|
+
out = []
|
|
227
|
+
for record in records or []:
|
|
228
|
+
try:
|
|
229
|
+
out.append(_jsonable(record_to_dict(record)))
|
|
230
|
+
except (AttributeError, TypeError, ValueError):
|
|
231
|
+
continue
|
|
232
|
+
return out
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ─── serialisation ──────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def to_dict(entry: Entry) -> dict[str, Any]:
|
|
239
|
+
a = entry.assessment
|
|
240
|
+
return {
|
|
241
|
+
"schema_version": SCHEMA_VERSION,
|
|
242
|
+
"repo": entry.repo,
|
|
243
|
+
"mode": entry.mode,
|
|
244
|
+
"created_at": entry.created_at,
|
|
245
|
+
"contributor_days": entry.contributor_days,
|
|
246
|
+
"duration_seconds": entry.duration_seconds,
|
|
247
|
+
"cost_usd": entry.cost_usd,
|
|
248
|
+
# The run, as it happened. Kept so that reopening a report can still
|
|
249
|
+
# show the trace behind it: the stream is the only record of what was
|
|
250
|
+
# dropped and why, and it used to die with the process.
|
|
251
|
+
"events": [d for d in map(event_to_dict, entry.events) if d is not None],
|
|
252
|
+
# The records the claims point at, so the ids under a reopened report
|
|
253
|
+
# still resolve. A live run's records are on no disk anywhere else:
|
|
254
|
+
# without this, its own report is the one report that cannot be checked.
|
|
255
|
+
"evidence": records_to_json(entry.evidence),
|
|
256
|
+
"assessment": {
|
|
257
|
+
"repo": a.repo,
|
|
258
|
+
"verdict": a.verdict.value,
|
|
259
|
+
"summary": a.summary,
|
|
260
|
+
"bottom_line": getattr(a, "bottom_line", ""),
|
|
261
|
+
"limits": getattr(a, "limits", ""),
|
|
262
|
+
"rules": list(getattr(a, "rules", []) or []),
|
|
263
|
+
"landing": list(getattr(a, "landing", []) or []),
|
|
264
|
+
"contributor_days": getattr(a, "contributor_days", 7),
|
|
265
|
+
"models": list(getattr(a, "models", []) or []),
|
|
266
|
+
"dropped_claims": getattr(a, "dropped_claims", 0),
|
|
267
|
+
"method": a.method,
|
|
268
|
+
"replayed": a.replayed,
|
|
269
|
+
"claims": [
|
|
270
|
+
{"text": c.text, "evidence_id": c.evidence_id} for c in a.claims
|
|
271
|
+
],
|
|
272
|
+
"entry_points": [
|
|
273
|
+
{
|
|
274
|
+
"evidence_id": p.evidence_id,
|
|
275
|
+
"first_step": p.first_step,
|
|
276
|
+
"why": p.why,
|
|
277
|
+
}
|
|
278
|
+
for p in (getattr(a, "entry_points", []) or [])
|
|
279
|
+
],
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def from_dict(raw: dict[str, Any], path: Path | None = None) -> Entry | None:
|
|
285
|
+
"""Rebuild an entry, or `None` if the file is not one we understand.
|
|
286
|
+
|
|
287
|
+
Returns rather than raises: a single unreadable file must not take the
|
|
288
|
+
history list down with it.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
if raw.get("schema_version") != SCHEMA_VERSION:
|
|
292
|
+
return None
|
|
293
|
+
body = raw["assessment"]
|
|
294
|
+
try:
|
|
295
|
+
verdict = Verdict(body["verdict"])
|
|
296
|
+
except ValueError:
|
|
297
|
+
# A verdict this build has never heard of. Keeping the entry out of
|
|
298
|
+
# history is safer than inventing a member for it.
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
assessment = Assessment(
|
|
302
|
+
repo=body["repo"],
|
|
303
|
+
verdict=verdict,
|
|
304
|
+
summary=body.get("summary", ""),
|
|
305
|
+
claims=[
|
|
306
|
+
Claim(text=c.get("text", ""), evidence_id=c.get("evidence_id"))
|
|
307
|
+
for c in body.get("claims", [])
|
|
308
|
+
],
|
|
309
|
+
method=body.get("method", "holt"),
|
|
310
|
+
replayed=bool(body.get("replayed", False)),
|
|
311
|
+
)
|
|
312
|
+
# Fields the engine has added over time. Set only when this build's
|
|
313
|
+
# `Assessment` actually carries them, so an older or newer engine does
|
|
314
|
+
# not turn a cached file into an AttributeError.
|
|
315
|
+
for name, value in (
|
|
316
|
+
("bottom_line", body.get("bottom_line", "")),
|
|
317
|
+
("limits", body.get("limits", "")),
|
|
318
|
+
("rules", list(body.get("rules", []) or [])),
|
|
319
|
+
("landing", list(body.get("landing", []) or [])),
|
|
320
|
+
("contributor_days", body.get("contributor_days", 7)),
|
|
321
|
+
("models", list(body.get("models", []) or [])),
|
|
322
|
+
("dropped_claims", body.get("dropped_claims", 0)),
|
|
323
|
+
):
|
|
324
|
+
if hasattr(assessment, name):
|
|
325
|
+
setattr(assessment, name, value)
|
|
326
|
+
if hasattr(assessment, "entry_points"):
|
|
327
|
+
assessment.entry_points = [
|
|
328
|
+
EntryPoint(
|
|
329
|
+
evidence_id=p.get("evidence_id", ""),
|
|
330
|
+
first_step=p.get("first_step", ""),
|
|
331
|
+
why=p.get("why", ""),
|
|
332
|
+
)
|
|
333
|
+
for p in body.get("entry_points", [])
|
|
334
|
+
]
|
|
335
|
+
|
|
336
|
+
return Entry(
|
|
337
|
+
repo=raw["repo"],
|
|
338
|
+
mode=raw.get("mode", "replay"),
|
|
339
|
+
created_at=float(raw.get("created_at", 0.0)),
|
|
340
|
+
assessment=assessment,
|
|
341
|
+
contributor_days=int(raw.get("contributor_days", 7)),
|
|
342
|
+
duration_seconds=float(raw.get("duration_seconds", 0.0)),
|
|
343
|
+
cost_usd=float(raw.get("cost_usd", 0.0)),
|
|
344
|
+
events=[
|
|
345
|
+
event
|
|
346
|
+
for event in map(event_from_dict, raw.get("events", []) or [])
|
|
347
|
+
if event is not None
|
|
348
|
+
],
|
|
349
|
+
evidence=[
|
|
350
|
+
record
|
|
351
|
+
for record in map(record_or_none, raw.get("evidence", []) or [])
|
|
352
|
+
if record is not None
|
|
353
|
+
],
|
|
354
|
+
path=path,
|
|
355
|
+
)
|
|
356
|
+
except (KeyError, TypeError, ValueError):
|
|
357
|
+
return None
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
# ─── the store ──────────────────────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass
|
|
364
|
+
class Store:
|
|
365
|
+
root: Path = DEFAULT_ROOT
|
|
366
|
+
#: Set when the directory cannot be written. The interface stays usable and
|
|
367
|
+
#: says so once, rather than failing every time an assessment finishes.
|
|
368
|
+
read_only: bool = False
|
|
369
|
+
_memory: list[Entry] = field(default_factory=list)
|
|
370
|
+
|
|
371
|
+
def save(self, entry: Entry) -> Entry:
|
|
372
|
+
"""Write an assessment down. Always succeeds from the caller's view."""
|
|
373
|
+
self._memory = [e for e in self._memory if e.key != entry.key]
|
|
374
|
+
self._memory.insert(0, entry)
|
|
375
|
+
try:
|
|
376
|
+
self.root.mkdir(parents=True, exist_ok=True)
|
|
377
|
+
path = self.root / _filename(entry)
|
|
378
|
+
_atomic_write(path, to_dict(entry))
|
|
379
|
+
entry.path = path
|
|
380
|
+
self.read_only = False
|
|
381
|
+
except OSError:
|
|
382
|
+
# Kept in memory for this session; history simply will not survive
|
|
383
|
+
# the process. Not worth interrupting anyone over.
|
|
384
|
+
self.read_only = True
|
|
385
|
+
return entry
|
|
386
|
+
|
|
387
|
+
def all(self) -> list[Entry]:
|
|
388
|
+
"""Every stored assessment, newest first. Unreadable files are skipped."""
|
|
389
|
+
found: dict[tuple[str, str, int], Entry] = {}
|
|
390
|
+
for entry in self._memory:
|
|
391
|
+
found.setdefault(entry.key, entry)
|
|
392
|
+
|
|
393
|
+
try:
|
|
394
|
+
paths = sorted(self.root.glob("*.json"))
|
|
395
|
+
except OSError:
|
|
396
|
+
paths = []
|
|
397
|
+
for path in paths:
|
|
398
|
+
try:
|
|
399
|
+
raw = json.loads(path.read_text())
|
|
400
|
+
except (OSError, json.JSONDecodeError):
|
|
401
|
+
continue
|
|
402
|
+
entry = from_dict(raw, path)
|
|
403
|
+
if entry is None:
|
|
404
|
+
continue
|
|
405
|
+
existing = found.get(entry.key)
|
|
406
|
+
if existing is None or entry.created_at > existing.created_at:
|
|
407
|
+
found[entry.key] = entry
|
|
408
|
+
|
|
409
|
+
return sorted(found.values(), key=lambda e: e.created_at, reverse=True)
|
|
410
|
+
|
|
411
|
+
def latest(self, repo: str, mode: str, contributor_days: int = 7) -> Entry | None:
|
|
412
|
+
"""The most recent assessment of exactly this question, if any."""
|
|
413
|
+
key = (repo, mode, contributor_days)
|
|
414
|
+
for entry in self.all():
|
|
415
|
+
if entry.key == key:
|
|
416
|
+
return entry
|
|
417
|
+
return None
|
|
418
|
+
|
|
419
|
+
def fresh(
|
|
420
|
+
self,
|
|
421
|
+
repo: str,
|
|
422
|
+
mode: str,
|
|
423
|
+
contributor_days: int = 7,
|
|
424
|
+
max_age: float = DEFAULT_MAX_AGE_SECONDS,
|
|
425
|
+
) -> Entry | None:
|
|
426
|
+
"""A reusable answer, or `None`. The caller must still show its age."""
|
|
427
|
+
entry = self.latest(repo, mode, contributor_days)
|
|
428
|
+
return entry if entry is not None and entry.fresh(max_age) else None
|
|
429
|
+
|
|
430
|
+
def forget(self, entry: Entry) -> None:
|
|
431
|
+
self._memory = [e for e in self._memory if e.key != entry.key]
|
|
432
|
+
if entry.path is not None:
|
|
433
|
+
try:
|
|
434
|
+
entry.path.unlink()
|
|
435
|
+
except OSError:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _filename(entry: Entry) -> str:
|
|
440
|
+
slug = entry.repo.replace("/", "__")
|
|
441
|
+
return f"{slug}__{entry.mode}__{entry.contributor_days}d.json"
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _atomic_write(path: Path, payload: dict[str, Any]) -> None:
|
|
445
|
+
"""Temp file then rename, so a kill mid-write cannot truncate history."""
|
|
446
|
+
handle = tempfile.NamedTemporaryFile(
|
|
447
|
+
"w", dir=path.parent, prefix=".tmp-", suffix=".json", delete=False
|
|
448
|
+
)
|
|
449
|
+
try:
|
|
450
|
+
with handle:
|
|
451
|
+
json.dump(payload, handle, indent=1)
|
|
452
|
+
os.replace(handle.name, path)
|
|
453
|
+
except BaseException:
|
|
454
|
+
try:
|
|
455
|
+
os.unlink(handle.name)
|
|
456
|
+
except OSError:
|
|
457
|
+
pass
|
|
458
|
+
raise
|