cctally 1.92.2 → 1.93.0
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.
- package/CHANGELOG.md +43 -0
- package/bin/_cctally_cache.py +354 -0
- package/bin/_cctally_core.py +180 -3
- package/bin/_cctally_dashboard.py +71 -1
- package/bin/_cctally_dashboard_envelope.py +28 -2
- package/bin/_cctally_dashboard_share.py +75 -19
- package/bin/_cctally_dashboard_sources.py +12 -0
- package/bin/_cctally_db.py +89 -1
- package/bin/_cctally_doctor.py +31 -0
- package/bin/_cctally_forecast.py +4 -2
- package/bin/_cctally_journal.py +3482 -258
- package/bin/_cctally_journal_repair.py +123 -32
- package/bin/_cctally_milestone_history.py +4 -1
- package/bin/_cctally_project.py +8 -6
- package/bin/_cctally_quota.py +420 -20
- package/bin/_cctally_rederive.py +57 -23
- package/bin/_cctally_reporting.py +8 -6
- package/bin/_cctally_share.py +74 -37
- package/bin/_cctally_source_analytics.py +6 -8
- package/bin/_cctally_store.py +13 -2
- package/bin/_cctally_tui.py +53 -0
- package/bin/_lib_cache_coverage.py +547 -0
- package/bin/_lib_doctor.py +54 -2
- package/bin/_lib_journal.py +235 -95
- package/bin/_lib_journal_router.py +21 -0
- package/bin/_lib_segment_summary.py +374 -0
- package/bin/_lib_selector_state.py +959 -0
- package/bin/_lib_share.py +1073 -165
- package/bin/_lib_share_templates.py +35 -11
- package/bin/_lib_stats_wal.py +327 -0
- package/bin/_lib_view_models.py +2 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +1 -0
- package/dashboard/static/assets/{index-Dat-mza6.js → index-HlIK7k8Q.js} +47 -47
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +5 -1
- package/dashboard/static/assets/index-DnWdv8um.css +0 -1
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
"""Journal segment summaries and the elision predicate — the pure kernel.
|
|
2
|
+
|
|
3
|
+
#496 S5b Stage 4, spec section 5. A rebuild reads every journal byte on every
|
|
4
|
+
pass. Six of the maintainer's seven bootstrap segments hold nothing but Codex
|
|
5
|
+
quota observations — roughly 1.64 GB of raw bytes that contribute no distinct
|
|
6
|
+
effective event, because Stage 3's coverage certificate already proves the cache
|
|
7
|
+
holds them. This module decides when such a segment may be skipped, and records
|
|
8
|
+
exactly what the skipping pass must contribute in its place.
|
|
9
|
+
|
|
10
|
+
**What an elided segment still contributes, and why each piece exists.**
|
|
11
|
+
|
|
12
|
+
Its **exact count of `decoded` entries**. The rebuild appends one element to
|
|
13
|
+
`decoded` for every valid decoded record — the record itself when retained, an
|
|
14
|
+
explicit `None` placeholder otherwise. `resolve_effective_events` numbers
|
|
15
|
+
candidates with `enumerate(records)`, three of the seven structural violation
|
|
16
|
+
kinds put that number inside `ProtocolViolation.evidence`, and the fingerprint
|
|
17
|
+
hashes it. That fingerprint is durable: it lands in
|
|
18
|
+
`journal_protocol_violations` and is referenced BY NAME from a
|
|
19
|
+
`journal_protocol_resolution` op, which `bin/_cctally_journal_repair.py` mints
|
|
20
|
+
from the UNFILTERED record list. Contributing the wrong count renumbers every
|
|
21
|
+
later candidate and makes a previously acknowledged violation unresolvable.
|
|
22
|
+
Malformed lines produce no `decoded` entry and are counted separately, so they
|
|
23
|
+
must never be folded into this number.
|
|
24
|
+
|
|
25
|
+
Its **partial last-seen fold**. `LastSeenAccumulator` is a per-key maximum over
|
|
26
|
+
timestamps, and maximum is associative and commutative, so a segment's partial
|
|
27
|
+
map merges into the running one in any order. The deferred Claude legacy bucket
|
|
28
|
+
still resolves against the cutover account at the end of the pass, and a
|
|
29
|
+
quota-only segment cannot contain the cutover op, which is an `op` and therefore
|
|
30
|
+
a retained record type.
|
|
31
|
+
|
|
32
|
+
Its **line, byte, decode and malformed counters**, so the rebuild record reports
|
|
33
|
+
the same traversal totals a non-eliding pass reports.
|
|
34
|
+
|
|
35
|
+
**What it cannot contribute is the prefix hash.** `PrefixHashAccumulator`
|
|
36
|
+
absorbs completed segments into one sequential `sha256` and `hashlib` can
|
|
37
|
+
neither export nor restore midstate, so a digest over a prefix containing an
|
|
38
|
+
elided segment is not computable from the bytes a skipping pass read. Elision is
|
|
39
|
+
therefore optimistic: a pass that meets a `journal_protocol_resolution` op
|
|
40
|
+
abandons the accumulator and re-reads the prefix from disk for the exact digest.
|
|
41
|
+
|
|
42
|
+
**Why the extent check is a three-way equality.** `_repair_torn_tail` truncates
|
|
43
|
+
a partial trailing line before appending, so a segment's size can decrease or
|
|
44
|
+
land back on exactly its previous value while its bytes and its decoded-entry
|
|
45
|
+
count both change — "an append strictly increases `st_size`" is false. Storing
|
|
46
|
+
only the verified newline boundary collapses the comparison: for a segment of
|
|
47
|
+
raw size 120 whose last newline sits at 100, every operand becomes 100 and a
|
|
48
|
+
permanently torn segment passes. The summary therefore carries BOTH the raw size
|
|
49
|
+
it summarized and the complete-line offset it covered, the pinned vector carries
|
|
50
|
+
the raw `st_size`, and elision requires all three to be equal.
|
|
51
|
+
|
|
52
|
+
This module performs no I/O beyond the sidecar itself and imports nothing from
|
|
53
|
+
`_cctally_journal`, so it is unit-testable without a journal on disk — the same
|
|
54
|
+
rule `bin/_lib_journal_router.py` and `bin/_lib_cache_coverage.py` follow. The
|
|
55
|
+
two modules it does import, `_lib_journal_router` and `_cctally_core`, are
|
|
56
|
+
themselves leaf modules; they are imported for the two constants
|
|
57
|
+
`summary_version` is derived from, not for behaviour.
|
|
58
|
+
"""
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import hashlib
|
|
62
|
+
import json
|
|
63
|
+
import os
|
|
64
|
+
import pathlib
|
|
65
|
+
import tempfile
|
|
66
|
+
from dataclasses import dataclass, field, replace
|
|
67
|
+
|
|
68
|
+
import _cctally_core
|
|
69
|
+
import _lib_journal_router
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
#: The sidecar basename, beside the journal segments it describes. Journal-side
|
|
73
|
+
#: rather than in `stats.db`, because a rebuild frequently runs precisely when
|
|
74
|
+
#: `stats.db` is absent or unreadable. It mirrors `.quota-observation-keys` in
|
|
75
|
+
#: placement but is a separate artifact: that sidecar stores quota natural-key
|
|
76
|
+
#: digests only, with no segment identity, offsets, ordering contribution or
|
|
77
|
+
#: selector semantics, so it cannot serve as coverage.
|
|
78
|
+
SIDECAR_NAME = ".segment-summaries"
|
|
79
|
+
|
|
80
|
+
#: Bumped by hand when a summary's FIELD SET or the meaning of one of its
|
|
81
|
+
#: numbers changes. It is only one of the three inputs to `summary_version`; the
|
|
82
|
+
#: other two are derived, for the reason that function's docstring gives.
|
|
83
|
+
SUMMARY_SHAPE_VERSION = 2
|
|
84
|
+
|
|
85
|
+
#: The refusal reasons `summary_is_elidable` returns. They are stable, because
|
|
86
|
+
#: the rebuild record reports them (spec section 6.3, "recorded, not silent").
|
|
87
|
+
REASON_OK = "ok"
|
|
88
|
+
REASON_RESOLUTION = "resolutionSeen"
|
|
89
|
+
REASON_LAST = "lastSegment"
|
|
90
|
+
REASON_IDENTITY = "identityMismatch"
|
|
91
|
+
REASON_EXTENT = "extentMismatch"
|
|
92
|
+
REASON_RETAINED = "retainedRecords"
|
|
93
|
+
REASON_UNCOVERED = "notCovered"
|
|
94
|
+
REASON_NO_DECODED_COUNT = "decodedCountAbsent"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class SegmentSummary:
|
|
99
|
+
"""Everything a pass needs to skip one segment without reading it.
|
|
100
|
+
|
|
101
|
+
Every field has a default so a test can construct the shape it is asserting
|
|
102
|
+
about without restating the rest, and so a later field addition does not
|
|
103
|
+
break a construction site that predates it. The sidecar reader supplies
|
|
104
|
+
every field explicitly, so a defaulted value never reaches a real decision.
|
|
105
|
+
"""
|
|
106
|
+
|
|
107
|
+
segment_name: str = ""
|
|
108
|
+
st_dev: int = 0
|
|
109
|
+
st_ino: int = 0
|
|
110
|
+
#: The raw `st_size` observed when this summary was written.
|
|
111
|
+
summarized_size: int = 0
|
|
112
|
+
#: The offset after the last COMPLETE line within `summarized_size`.
|
|
113
|
+
complete_line_covered_offset: int = 0
|
|
114
|
+
lines: int = 0
|
|
115
|
+
bytes: int = 0
|
|
116
|
+
decodes: int = 0
|
|
117
|
+
malformed: int = 0
|
|
118
|
+
#: True when the segment holds ZERO records of a retained type.
|
|
119
|
+
quota_only: bool = True
|
|
120
|
+
#: Elements the segment contributed to the rebuild's `decoded` list —
|
|
121
|
+
#: retained records plus placeholders, and never a malformed line. `None`
|
|
122
|
+
#: means the summary predates the field and cannot be elided against.
|
|
123
|
+
decoded_entry_count: "int | None" = 0
|
|
124
|
+
#: The segment's partial `LastSeenAccumulator` state.
|
|
125
|
+
last_seen_stamped: dict = field(default_factory=dict)
|
|
126
|
+
last_seen_legacy_claude_at: "str | None" = None
|
|
127
|
+
last_seen_legacy_codex_at: "str | None" = None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def summary_is_elidable(
|
|
131
|
+
summary, *, pinned_raw_extent, is_last, certificate_covers,
|
|
132
|
+
resolution_seen, stat_identity=None,
|
|
133
|
+
) -> "tuple[bool, str]":
|
|
134
|
+
"""``(verdict, reason)`` for one segment against spec section 5.5.
|
|
135
|
+
|
|
136
|
+
``stat_identity`` is the ``(st_dev, st_ino)`` of the file on disk. It is
|
|
137
|
+
optional because the caller that has already stat'd the segment passes it
|
|
138
|
+
and a unit test asserting one of the other conditions does not; when it is
|
|
139
|
+
absent the identity condition is the caller's to have checked.
|
|
140
|
+
|
|
141
|
+
The order is deliberate, and two parts of it change behaviour rather than
|
|
142
|
+
only wording. `resolution_seen` comes first because it is a property of the
|
|
143
|
+
PASS rather than of the segment, so reporting a segment-shaped reason for it
|
|
144
|
+
would send a reader looking at the wrong file. And the EXTENT check comes
|
|
145
|
+
before the COVERAGE check, because a torn-tail repair moves both: it changes
|
|
146
|
+
the segment's complete-line offset, which is part of the pinned vector, so
|
|
147
|
+
the certificate's identity root moves with it and coverage would refuse the
|
|
148
|
+
segment too. Reporting `notCovered` there would name the certificate for a
|
|
149
|
+
segment the summary's own extent already disqualifies, and a test asserting
|
|
150
|
+
the refusal REASON is what distinguishes the two mechanisms. Reordering
|
|
151
|
+
these two is therefore a behaviour change, not a cleanup.
|
|
152
|
+
"""
|
|
153
|
+
if resolution_seen:
|
|
154
|
+
return False, REASON_RESOLUTION
|
|
155
|
+
if is_last:
|
|
156
|
+
return False, REASON_LAST
|
|
157
|
+
if stat_identity is not None:
|
|
158
|
+
if (int(summary.st_dev), int(summary.st_ino)) != (
|
|
159
|
+
int(stat_identity[0]), int(stat_identity[1])):
|
|
160
|
+
return False, REASON_IDENTITY
|
|
161
|
+
# ONE equality over three operands, not two comparisons: see the module
|
|
162
|
+
# docstring for why the raw size and the newline boundary are independent.
|
|
163
|
+
if not (int(summary.complete_line_covered_offset)
|
|
164
|
+
== int(summary.summarized_size)
|
|
165
|
+
== int(pinned_raw_extent)):
|
|
166
|
+
return False, REASON_EXTENT
|
|
167
|
+
if not summary.quota_only:
|
|
168
|
+
return False, REASON_RETAINED
|
|
169
|
+
if not certificate_covers:
|
|
170
|
+
return False, REASON_UNCOVERED
|
|
171
|
+
if summary.decoded_entry_count is None:
|
|
172
|
+
return False, REASON_NO_DECODED_COUNT
|
|
173
|
+
return True, REASON_OK
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
_FIELDS = (
|
|
177
|
+
"segment_name", "st_dev", "st_ino", "summarized_size",
|
|
178
|
+
"complete_line_covered_offset", "lines", "bytes", "decodes", "malformed",
|
|
179
|
+
"quota_only", "decoded_entry_count", "last_seen_stamped",
|
|
180
|
+
"last_seen_legacy_claude_at", "last_seen_legacy_codex_at",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def summary_version() -> str:
|
|
185
|
+
"""The version token the sidecar is written with and validated against.
|
|
186
|
+
|
|
187
|
+
DERIVED rather than hand-written, because the two things a stale summary can
|
|
188
|
+
silently get wrong are both defined elsewhere and neither would remind
|
|
189
|
+
anybody to bump a literal here.
|
|
190
|
+
|
|
191
|
+
`quota_only` is computed from `RETAINED_RECORD_TYPES` at write time. If a
|
|
192
|
+
record type later joins that set, a summary written before the change still
|
|
193
|
+
says `quota_only=True` for a segment that now holds a retained record, the
|
|
194
|
+
segment is elided, and its retained records are replaced by placeholders —
|
|
195
|
+
a wrong selection with no fallback and nothing on stderr. Hashing the sorted
|
|
196
|
+
set makes that change discard every existing sidecar instead.
|
|
197
|
+
|
|
198
|
+
`STATS_INDEX_EPOCH` is the repository's existing marker for "what a rebuild
|
|
199
|
+
derives from the journal changed", and every counter in a summary is part of
|
|
200
|
+
that derivation. The coverage certificate's `interpretationVersion` does not
|
|
201
|
+
cover this: it tracks cache materialization, not the stats selector.
|
|
202
|
+
|
|
203
|
+
`SUMMARY_SHAPE_VERSION` stays hand-written for the one change neither
|
|
204
|
+
derived input can see — a field added to, removed from or redefined within
|
|
205
|
+
the summary itself.
|
|
206
|
+
|
|
207
|
+
Discarding costs exactly one full read, and the sidecar is re-derived by the
|
|
208
|
+
pass that discarded it.
|
|
209
|
+
"""
|
|
210
|
+
material = json.dumps(
|
|
211
|
+
{
|
|
212
|
+
"shape": SUMMARY_SHAPE_VERSION,
|
|
213
|
+
"fields": list(_FIELDS),
|
|
214
|
+
"retained": sorted(_lib_journal_router.RETAINED_RECORD_TYPES),
|
|
215
|
+
"epoch": int(_cctally_core.STATS_INDEX_EPOCH),
|
|
216
|
+
},
|
|
217
|
+
separators=(",", ":"), sort_keys=True,
|
|
218
|
+
)
|
|
219
|
+
return "v" + hashlib.sha256(material.encode("utf-8")).hexdigest()[:16]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def to_wire(summary) -> dict:
|
|
223
|
+
"""One summary as a JSON-safe dict, in the canonical field order."""
|
|
224
|
+
return {
|
|
225
|
+
"segment_name": str(summary.segment_name),
|
|
226
|
+
"st_dev": int(summary.st_dev),
|
|
227
|
+
"st_ino": int(summary.st_ino),
|
|
228
|
+
"summarized_size": int(summary.summarized_size),
|
|
229
|
+
"complete_line_covered_offset": int(
|
|
230
|
+
summary.complete_line_covered_offset),
|
|
231
|
+
"lines": int(summary.lines),
|
|
232
|
+
"bytes": int(summary.bytes),
|
|
233
|
+
"decodes": int(summary.decodes),
|
|
234
|
+
"malformed": int(summary.malformed),
|
|
235
|
+
"quota_only": bool(summary.quota_only),
|
|
236
|
+
"decoded_entry_count": (
|
|
237
|
+
None if summary.decoded_entry_count is None
|
|
238
|
+
else int(summary.decoded_entry_count)),
|
|
239
|
+
"last_seen_stamped": {
|
|
240
|
+
str(key): str(value)
|
|
241
|
+
for key, value in dict(summary.last_seen_stamped).items()
|
|
242
|
+
},
|
|
243
|
+
"last_seen_legacy_claude_at": (
|
|
244
|
+
None if summary.last_seen_legacy_claude_at is None
|
|
245
|
+
else str(summary.last_seen_legacy_claude_at)),
|
|
246
|
+
"last_seen_legacy_codex_at": (
|
|
247
|
+
None if summary.last_seen_legacy_codex_at is None
|
|
248
|
+
else str(summary.last_seen_legacy_codex_at)),
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def from_wire(item) -> SegmentSummary:
|
|
253
|
+
"""One summary from its wire form. Raises on any shape it did not write."""
|
|
254
|
+
if not isinstance(item, dict):
|
|
255
|
+
raise ValueError("segment summary is not an object")
|
|
256
|
+
missing = [name for name in _FIELDS if name not in item]
|
|
257
|
+
if missing:
|
|
258
|
+
raise ValueError(f"segment summary is missing {missing}")
|
|
259
|
+
stamped = item["last_seen_stamped"]
|
|
260
|
+
if not isinstance(stamped, dict):
|
|
261
|
+
raise ValueError("last_seen_stamped is not an object")
|
|
262
|
+
count = item["decoded_entry_count"]
|
|
263
|
+
return SegmentSummary(
|
|
264
|
+
segment_name=str(item["segment_name"]),
|
|
265
|
+
st_dev=int(item["st_dev"]),
|
|
266
|
+
st_ino=int(item["st_ino"]),
|
|
267
|
+
summarized_size=int(item["summarized_size"]),
|
|
268
|
+
complete_line_covered_offset=int(item["complete_line_covered_offset"]),
|
|
269
|
+
lines=int(item["lines"]),
|
|
270
|
+
bytes=int(item["bytes"]),
|
|
271
|
+
decodes=int(item["decodes"]),
|
|
272
|
+
malformed=int(item["malformed"]),
|
|
273
|
+
quota_only=bool(item["quota_only"]),
|
|
274
|
+
decoded_entry_count=None if count is None else int(count),
|
|
275
|
+
last_seen_stamped={
|
|
276
|
+
str(key): str(value) for key, value in stamped.items()},
|
|
277
|
+
last_seen_legacy_claude_at=(
|
|
278
|
+
None if item["last_seen_legacy_claude_at"] is None
|
|
279
|
+
else str(item["last_seen_legacy_claude_at"])),
|
|
280
|
+
last_seen_legacy_codex_at=(
|
|
281
|
+
None if item["last_seen_legacy_codex_at"] is None
|
|
282
|
+
else str(item["last_seen_legacy_codex_at"])),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _checksum(segments) -> str:
|
|
287
|
+
payload = json.dumps(segments, separators=(",", ":"), sort_keys=True,
|
|
288
|
+
ensure_ascii=False)
|
|
289
|
+
return "sha256:" + hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def write_sidecar(path, summaries) -> None:
|
|
293
|
+
"""Replace the sidecar at ``path`` with ``summaries``, atomically.
|
|
294
|
+
|
|
295
|
+
Atomic because a torn write is indistinguishable from a stale one to the
|
|
296
|
+
reader, and the reader's only safe answer to either is to discard the whole
|
|
297
|
+
file — which would silently retire elision until the next successful write.
|
|
298
|
+
Failures are swallowed: the sidecar is a pure cache and a pass that could
|
|
299
|
+
not write it is still a correct pass.
|
|
300
|
+
"""
|
|
301
|
+
path = pathlib.Path(path)
|
|
302
|
+
segments = [to_wire(item) for item in summaries]
|
|
303
|
+
body = json.dumps(
|
|
304
|
+
{
|
|
305
|
+
"version": summary_version(),
|
|
306
|
+
"checksum": _checksum(segments),
|
|
307
|
+
"segments": segments,
|
|
308
|
+
},
|
|
309
|
+
separators=(",", ": "), sort_keys=True, ensure_ascii=False,
|
|
310
|
+
)
|
|
311
|
+
handle = None
|
|
312
|
+
tmp_name = None
|
|
313
|
+
try:
|
|
314
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
315
|
+
handle, tmp_name = tempfile.mkstemp(
|
|
316
|
+
prefix=path.name + ".", dir=str(path.parent))
|
|
317
|
+
with os.fdopen(handle, "w", encoding="utf-8") as out:
|
|
318
|
+
handle = None
|
|
319
|
+
out.write(body)
|
|
320
|
+
out.flush()
|
|
321
|
+
os.fsync(out.fileno())
|
|
322
|
+
os.chmod(tmp_name, 0o600)
|
|
323
|
+
os.replace(tmp_name, str(path))
|
|
324
|
+
tmp_name = None
|
|
325
|
+
except OSError:
|
|
326
|
+
return
|
|
327
|
+
finally:
|
|
328
|
+
if handle is not None:
|
|
329
|
+
os.close(handle)
|
|
330
|
+
if tmp_name is not None:
|
|
331
|
+
try:
|
|
332
|
+
os.unlink(tmp_name)
|
|
333
|
+
except OSError:
|
|
334
|
+
pass
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def read_sidecar(path):
|
|
338
|
+
"""``{segment_name: SegmentSummary}``, or None for anything unusable.
|
|
339
|
+
|
|
340
|
+
Invalid, truncated, version-mismatched or checksum-mismatched content is
|
|
341
|
+
DISCARDED rather than repaired. The sidecar is re-derivable from the journal
|
|
342
|
+
by the next non-eliding pass, so discarding costs one full read and repairing
|
|
343
|
+
would risk carrying a wrong extent or a wrong decoded-entry count forward —
|
|
344
|
+
the two values every later elision decision rests on.
|
|
345
|
+
"""
|
|
346
|
+
try:
|
|
347
|
+
raw = pathlib.Path(path).read_text(encoding="utf-8")
|
|
348
|
+
except (OSError, UnicodeError):
|
|
349
|
+
return None
|
|
350
|
+
try:
|
|
351
|
+
payload = json.loads(raw)
|
|
352
|
+
except ValueError:
|
|
353
|
+
return None
|
|
354
|
+
if not isinstance(payload, dict):
|
|
355
|
+
return None
|
|
356
|
+
if payload.get("version") != summary_version():
|
|
357
|
+
return None
|
|
358
|
+
segments = payload.get("segments")
|
|
359
|
+
if not isinstance(segments, list):
|
|
360
|
+
return None
|
|
361
|
+
try:
|
|
362
|
+
if payload.get("checksum") != _checksum(segments):
|
|
363
|
+
return None
|
|
364
|
+
return {
|
|
365
|
+
item.segment_name: item
|
|
366
|
+
for item in (from_wire(entry) for entry in segments)
|
|
367
|
+
}
|
|
368
|
+
except (AttributeError, TypeError, ValueError):
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def with_identity(summary, *, st_dev, st_ino) -> SegmentSummary:
|
|
373
|
+
"""``summary`` restamped with the inode identity of the file just read."""
|
|
374
|
+
return replace(summary, st_dev=int(st_dev), st_ino=int(st_ino))
|