cctally 1.117.2 → 1.118.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 +30 -0
- package/README.md +7 -3
- package/bin/_cctally_cache.py +2783 -175
- package/bin/_cctally_completion.py +106 -33
- package/bin/_cctally_core.py +69 -4
- package/bin/_cctally_dashboard.py +188 -13
- package/bin/_cctally_dashboard_conversation.py +5 -1
- package/bin/_cctally_dashboard_envelope.py +50 -19
- package/bin/_cctally_dashboard_sources.py +23 -1
- package/bin/_cctally_db.py +193 -153
- package/bin/_cctally_diagnosis_sources.py +63 -7
- package/bin/_cctally_doctor.py +865 -93
- package/bin/_cctally_five_hour.py +67 -50
- package/bin/_cctally_forecast.py +35 -13
- package/bin/_cctally_journal.py +186 -18
- package/bin/_cctally_milestone_history.py +859 -62
- package/bin/_cctally_oauth_samples.py +286 -15
- package/bin/_cctally_parser.py +2 -2
- package/bin/_cctally_quota.py +104 -12
- package/bin/_cctally_record.py +153 -29
- package/bin/_cctally_rederive.py +608 -68
- package/bin/_cctally_statusline.py +108 -81
- package/bin/_cctally_store.py +179 -38
- package/bin/_cctally_tui.py +892 -181
- package/bin/_cctally_update.py +32 -13
- package/bin/_lib_codex_conversation_query.py +52 -14
- package/bin/_lib_codex_drain_admission.py +224 -6
- package/bin/_lib_codex_metadata.py +39 -0
- package/bin/_lib_conversation_anon.py +15 -8
- package/bin/_lib_conversation_dispatch.py +7 -0
- package/bin/_lib_conversation_query.py +289 -70
- package/bin/_lib_dashboard_sources.py +37 -1
- package/bin/_lib_detector_access_registry.py +497 -26
- package/bin/_lib_doctor.py +85 -20
- package/bin/_lib_doctor_scan_index.py +321 -0
- package/bin/_lib_ingest_frontier.py +20 -1
- package/bin/_lib_oauth_samples.py +23 -7
- package/bin/_lib_pricing.py +146 -16
- package/bin/_lib_quota.py +19 -0
- package/bin/_lib_rederive.py +11 -6
- package/bin/_lib_retained_records.py +516 -0
- package/bin/_lib_snapshot_cache.py +10 -0
- package/bin/_lib_stale_proof.py +272 -43
- package/bin/_lib_stats_content_revision.py +831 -0
- package/bin/_lib_write_io.py +11 -4
- package/bin/cctally +4 -1
- package/dashboard/static/assets/ConversationsView-D4Iv4ku0.js +72 -0
- package/dashboard/static/assets/{DoctorModal-B0DnU9d9.js → DoctorModal-D4QSq6uz.js} +1 -1
- package/dashboard/static/assets/ModalRoot-C9F5dhZi.js +1 -0
- package/dashboard/static/assets/{ProjectsDrillPanel-DSxXQSa-.js → ProjectsDrillPanel-WxAdRDMt.js} +1 -1
- package/dashboard/static/assets/{SourceDetailModal-BleDpQqG.js → SourceDetailModal-NyyEfXmc.js} +1 -1
- package/dashboard/static/assets/{UpdateModal-CMqaAkSK.js → UpdateModal-CFLq9pT1.js} +1 -1
- package/dashboard/static/assets/{index-BjQV-0-D.js → index-BKTWH9JE.js} +2 -2
- package/dashboard/static/assets/{outlineNavigation-CO-VWBm7.js → outlineNavigation-BnGUwCS3.js} +3 -3
- package/dashboard/static/dashboard.html +1 -1
- package/package.json +4 -1
- package/dashboard/static/assets/ConversationsView-oqNsWzWV.js +0 -72
- package/dashboard/static/assets/ModalRoot-CYua2wP_.js +0 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Coordinate-backed retained journal records (#1004, spec D3 and D4).
|
|
2
|
+
|
|
3
|
+
A journal reader that keeps every record it decoded until its planning or
|
|
4
|
+
replay passes end holds ≈ 0.9 KB per record, plus two lists of
|
|
5
|
+
``(segment, offset)`` tuples at ≈ 100 bytes per record each. This module keeps
|
|
6
|
+
coordinates instead and re-reads a record's line when a consumer asks for it:
|
|
7
|
+
|
|
8
|
+
* `LineCoordinates` holds one ``array('Q')`` of line start offsets and one
|
|
9
|
+
``array('I')`` of line lengths (the newline excluded), 12 bytes a position,
|
|
10
|
+
plus a table of segment runs. Its ``starts()`` and ``ends()`` views yield
|
|
11
|
+
the ``(segment, start)`` and ``(segment, start + length + 1)`` tuples the
|
|
12
|
+
readers' two lists held.
|
|
13
|
+
* `RetainedRecords` is a sequence over those positions. A slot holds either a
|
|
14
|
+
retained object (an interned stub, an admission placeholder, a ``None``
|
|
15
|
+
slot) or the private `STREAMED` sentinel, which means "decode from the
|
|
16
|
+
coordinates". A streamed line's 8-byte BLAKE2b digest is taken in the read
|
|
17
|
+
pass and checked on every re-read, so a rewrite of the same length, which
|
|
18
|
+
framing alone cannot detect, raises too.
|
|
19
|
+
|
|
20
|
+
Pure and stdlib-only: the caller supplies the line reader, the decoder and the
|
|
21
|
+
error type, so the readers keep their own framing check and error contract.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import bisect
|
|
26
|
+
import hashlib
|
|
27
|
+
from array import array
|
|
28
|
+
from collections.abc import Sequence
|
|
29
|
+
from typing import Callable, Protocol
|
|
30
|
+
|
|
31
|
+
#: Test switch (#1004 T6): when true, the readers build today's list form (a
|
|
32
|
+
#: plain list of decoded records and a plain list of end tuples) instead of a
|
|
33
|
+
#: `RetainedRecords`. Read at reader time. Never set by product code.
|
|
34
|
+
MATERIALIZE_FOR_TESTS = False
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class _Streamed:
|
|
38
|
+
"""The slot marker for "decode this position from its coordinates"."""
|
|
39
|
+
|
|
40
|
+
__slots__ = ()
|
|
41
|
+
|
|
42
|
+
def __repr__(self) -> str:
|
|
43
|
+
return "STREAMED"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: The one slot value meaning a position is decoded on access.
|
|
47
|
+
STREAMED = _Streamed()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def line_digest(line: bytes) -> int:
|
|
51
|
+
"""The 8-byte BLAKE2b digest of ``line``, as an unsigned integer."""
|
|
52
|
+
return int.from_bytes(
|
|
53
|
+
hashlib.blake2b(line, digest_size=8).digest(), "little")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _sequence_eq(left, right):
|
|
57
|
+
if not isinstance(right, (list, tuple, Sequence)) or isinstance(
|
|
58
|
+
right, (str, bytes)):
|
|
59
|
+
return NotImplemented
|
|
60
|
+
if len(left) != len(right):
|
|
61
|
+
return False
|
|
62
|
+
return all(a == b for a, b in zip(left, right))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# --------------------------------------------------------------------------
|
|
66
|
+
# D3: per-position coordinates
|
|
67
|
+
# --------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class LineCoordinates:
|
|
71
|
+
"""Per-position line coordinates in two arrays and a segment-run table.
|
|
72
|
+
|
|
73
|
+
``append(segment, start, length)`` adds one position: ``start`` is the
|
|
74
|
+
line's first byte and ``length`` excludes its newline. Positions are in
|
|
75
|
+
journal order, so within one run the end coordinates strictly increase.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
__slots__ = ("_starts", "_lengths", "_run_segments", "_run_firsts",
|
|
79
|
+
"__weakref__")
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self._starts = array("Q")
|
|
83
|
+
self._lengths = array("I")
|
|
84
|
+
#: One entry per run of consecutive positions in the same segment.
|
|
85
|
+
self._run_segments: list = []
|
|
86
|
+
self._run_firsts = array("Q")
|
|
87
|
+
|
|
88
|
+
def append(self, segment: str, start: int, length: int) -> None:
|
|
89
|
+
segments = self._run_segments
|
|
90
|
+
if not segments or segments[-1] != segment:
|
|
91
|
+
segments.append(segment)
|
|
92
|
+
self._run_firsts.append(len(self._starts))
|
|
93
|
+
self._starts.append(start)
|
|
94
|
+
self._lengths.append(length)
|
|
95
|
+
|
|
96
|
+
def __len__(self) -> int:
|
|
97
|
+
return len(self._starts)
|
|
98
|
+
|
|
99
|
+
def _run_of(self, index: int) -> int:
|
|
100
|
+
return bisect.bisect_right(self._run_firsts, index) - 1
|
|
101
|
+
|
|
102
|
+
def _normalize(self, index: int) -> int:
|
|
103
|
+
size = len(self._starts)
|
|
104
|
+
if index < 0:
|
|
105
|
+
index += size
|
|
106
|
+
if not 0 <= index < size:
|
|
107
|
+
raise IndexError("line coordinate index out of range")
|
|
108
|
+
return index
|
|
109
|
+
|
|
110
|
+
def line(self, i: int) -> "tuple[str, int, int]":
|
|
111
|
+
"""``(segment, start, length)`` of position ``i``."""
|
|
112
|
+
i = self._normalize(i)
|
|
113
|
+
return (self._run_segments[self._run_of(i)], self._starts[i],
|
|
114
|
+
self._lengths[i])
|
|
115
|
+
|
|
116
|
+
def _start(self, i: int):
|
|
117
|
+
i = self._normalize(i)
|
|
118
|
+
return (self._run_segments[self._run_of(i)], self._starts[i])
|
|
119
|
+
|
|
120
|
+
def _end(self, i: int):
|
|
121
|
+
i = self._normalize(i)
|
|
122
|
+
return (self._run_segments[self._run_of(i)],
|
|
123
|
+
self._starts[i] + self._lengths[i] + 1)
|
|
124
|
+
|
|
125
|
+
def _iter(self, positions, end: bool):
|
|
126
|
+
starts = self._starts
|
|
127
|
+
lengths = self._lengths
|
|
128
|
+
segments = self._run_segments
|
|
129
|
+
firsts = self._run_firsts
|
|
130
|
+
run = -1
|
|
131
|
+
run_stop = -1
|
|
132
|
+
segment = None
|
|
133
|
+
for i in positions:
|
|
134
|
+
if not (run >= 0 and firsts[run] <= i < run_stop):
|
|
135
|
+
run = bisect.bisect_right(firsts, i) - 1
|
|
136
|
+
segment = segments[run]
|
|
137
|
+
run_stop = (firsts[run + 1] if run + 1 < len(firsts)
|
|
138
|
+
else len(starts))
|
|
139
|
+
if end:
|
|
140
|
+
yield (segment, starts[i] + lengths[i] + 1)
|
|
141
|
+
else:
|
|
142
|
+
yield (segment, starts[i])
|
|
143
|
+
|
|
144
|
+
def _index_end(self, cursor) -> int:
|
|
145
|
+
"""The position whose end coordinate is ``cursor``; ValueError when
|
|
146
|
+
none is. A binary search inside each run of ``cursor``'s segment."""
|
|
147
|
+
try:
|
|
148
|
+
segment, end = cursor
|
|
149
|
+
except (TypeError, ValueError):
|
|
150
|
+
raise ValueError(f"{cursor!r} is not in list") from None
|
|
151
|
+
starts = self._starts
|
|
152
|
+
lengths = self._lengths
|
|
153
|
+
firsts = self._run_firsts
|
|
154
|
+
for run, name in enumerate(self._run_segments):
|
|
155
|
+
if name != segment:
|
|
156
|
+
continue
|
|
157
|
+
lo = firsts[run]
|
|
158
|
+
hi = firsts[run + 1] if run + 1 < len(firsts) else len(starts)
|
|
159
|
+
while lo < hi:
|
|
160
|
+
mid = (lo + hi) // 2
|
|
161
|
+
value = starts[mid] + lengths[mid] + 1
|
|
162
|
+
if value < end:
|
|
163
|
+
lo = mid + 1
|
|
164
|
+
elif value > end:
|
|
165
|
+
hi = mid
|
|
166
|
+
else:
|
|
167
|
+
return mid
|
|
168
|
+
raise ValueError(f"{cursor!r} is not in list")
|
|
169
|
+
|
|
170
|
+
def starts(self) -> "_CoordinateView":
|
|
171
|
+
"""Every position's ``(segment, start)``, as a view."""
|
|
172
|
+
return _CoordinateView(self, None, end=False)
|
|
173
|
+
|
|
174
|
+
def ends(self) -> "_CoordinateView":
|
|
175
|
+
"""Every position's ``(segment, start + length + 1)``, as a view."""
|
|
176
|
+
return _CoordinateView(self, None, end=True)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class _CoordinateView(Sequence):
|
|
180
|
+
"""A view of `LineCoordinates` positions as start or end tuples.
|
|
181
|
+
|
|
182
|
+
``positions`` is ``None`` for the whole (live) coordinate set, otherwise a
|
|
183
|
+
``range`` of positions. Slicing returns a view; nothing is copied."""
|
|
184
|
+
|
|
185
|
+
__slots__ = ("_coords", "_positions", "_end", "__weakref__")
|
|
186
|
+
|
|
187
|
+
def __init__(self, coords: LineCoordinates, positions, *, end: bool):
|
|
188
|
+
self._coords = coords
|
|
189
|
+
self._positions = positions
|
|
190
|
+
self._end = end
|
|
191
|
+
|
|
192
|
+
def _range(self):
|
|
193
|
+
if self._positions is None:
|
|
194
|
+
return range(len(self._coords))
|
|
195
|
+
return self._positions
|
|
196
|
+
|
|
197
|
+
def __len__(self) -> int:
|
|
198
|
+
return len(self._range())
|
|
199
|
+
|
|
200
|
+
def __getitem__(self, index):
|
|
201
|
+
positions = self._range()
|
|
202
|
+
if isinstance(index, slice):
|
|
203
|
+
return _CoordinateView(self._coords, positions[index],
|
|
204
|
+
end=self._end)
|
|
205
|
+
position = positions[index]
|
|
206
|
+
return (self._coords._end(position) if self._end
|
|
207
|
+
else self._coords._start(position))
|
|
208
|
+
|
|
209
|
+
def __iter__(self):
|
|
210
|
+
return self._coords._iter(self._range(), self._end)
|
|
211
|
+
|
|
212
|
+
def index(self, cursor, start=0, stop=None):
|
|
213
|
+
if not self._end:
|
|
214
|
+
return Sequence.index(self, cursor, start, stop)
|
|
215
|
+
position = self._coords._index_end(cursor)
|
|
216
|
+
positions = self._range()
|
|
217
|
+
try:
|
|
218
|
+
found = positions.index(position)
|
|
219
|
+
except ValueError:
|
|
220
|
+
raise ValueError(f"{cursor!r} is not in list") from None
|
|
221
|
+
if stop is None:
|
|
222
|
+
stop = len(positions)
|
|
223
|
+
if start < 0:
|
|
224
|
+
start = max(len(positions) + start, 0)
|
|
225
|
+
if stop < 0:
|
|
226
|
+
stop += len(positions)
|
|
227
|
+
if not start <= found < stop:
|
|
228
|
+
raise ValueError(f"{cursor!r} is not in list")
|
|
229
|
+
return found
|
|
230
|
+
|
|
231
|
+
def __eq__(self, other):
|
|
232
|
+
return _sequence_eq(self, other)
|
|
233
|
+
|
|
234
|
+
__hash__ = None # type: ignore[assignment]
|
|
235
|
+
|
|
236
|
+
def __repr__(self) -> str:
|
|
237
|
+
kind = "ends" if self._end else "starts"
|
|
238
|
+
return f"<LineCoordinates.{kind} view of {len(self)}>"
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
# --------------------------------------------------------------------------
|
|
242
|
+
# D4: coordinate-backed records
|
|
243
|
+
# --------------------------------------------------------------------------
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class LineReader(Protocol):
|
|
247
|
+
"""Reads one journal line back by its coordinates."""
|
|
248
|
+
|
|
249
|
+
def read(self, segment: str, start: int, length: int) -> bytes:
|
|
250
|
+
"""The ``length`` bytes at ``start``, verified to still be one framed
|
|
251
|
+
line; raises the caller's error on a framing mismatch."""
|
|
252
|
+
|
|
253
|
+
def close(self) -> None:
|
|
254
|
+
"""Release every handle."""
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
class RetainedRecords(Sequence):
|
|
258
|
+
"""A reader's retained records, streamed from coordinates (spec D4).
|
|
259
|
+
|
|
260
|
+
Each position's slot holds either a retained object or `STREAMED`. A
|
|
261
|
+
streamed position is re-read on access through a reader from
|
|
262
|
+
``open_reader``, digest-checked, decoded with ``decode`` and normalized in
|
|
263
|
+
place by the installed normalizer: each access returns a fresh private
|
|
264
|
+
object equal to the one the reader retained before. ``error(message)``
|
|
265
|
+
builds the exception a changed line raises."""
|
|
266
|
+
|
|
267
|
+
def __init__(self, coordinates: LineCoordinates,
|
|
268
|
+
open_reader: "Callable[[], LineReader]",
|
|
269
|
+
decode: "Callable[[bytes], dict]", *,
|
|
270
|
+
error: "Callable[[str], Exception]") -> None:
|
|
271
|
+
self._coords = coordinates
|
|
272
|
+
self._open_reader = open_reader
|
|
273
|
+
self._decode = decode
|
|
274
|
+
self._error = error
|
|
275
|
+
self._slots: list = []
|
|
276
|
+
#: One digest per position (zero for a retained slot).
|
|
277
|
+
self._digests = array("Q")
|
|
278
|
+
self._normalizer = None
|
|
279
|
+
self._reader = None
|
|
280
|
+
|
|
281
|
+
# -- building -----------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
def append_retained(self, obj, segment: str, start: int,
|
|
284
|
+
length: int) -> None:
|
|
285
|
+
self._coords.append(segment, start, length)
|
|
286
|
+
self._slots.append(obj)
|
|
287
|
+
self._digests.append(0)
|
|
288
|
+
|
|
289
|
+
def append_streamed(self, line: bytes, segment: str, start: int,
|
|
290
|
+
length: int) -> None:
|
|
291
|
+
self._coords.append(segment, start, length)
|
|
292
|
+
self._slots.append(STREAMED)
|
|
293
|
+
self._digests.append(line_digest(line))
|
|
294
|
+
|
|
295
|
+
def set_normalizer(self, fn: "Callable[[dict], None] | None") -> None:
|
|
296
|
+
"""Install ``fn``, applied in place to every fresh decode."""
|
|
297
|
+
self._normalizer = fn
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def coordinates(self) -> LineCoordinates:
|
|
301
|
+
return self._coords
|
|
302
|
+
|
|
303
|
+
# -- reading ------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
def _decode_at(self, reader, index: int):
|
|
306
|
+
segment, start, length = self._coords.line(index)
|
|
307
|
+
line = reader.read(segment, start, length)
|
|
308
|
+
if line_digest(line) != self._digests[index]:
|
|
309
|
+
raise self._error(
|
|
310
|
+
f"{segment}@{start}: the line the read pass saw has changed")
|
|
311
|
+
record = self._decode(line)
|
|
312
|
+
if record is None:
|
|
313
|
+
raise self._error(
|
|
314
|
+
f"{segment}@{start}: the line the read pass saw no longer "
|
|
315
|
+
"decodes")
|
|
316
|
+
if self._normalizer is not None:
|
|
317
|
+
self._normalizer(record)
|
|
318
|
+
return record
|
|
319
|
+
|
|
320
|
+
def _random_reader(self):
|
|
321
|
+
if self._reader is None:
|
|
322
|
+
self._reader = self._open_reader()
|
|
323
|
+
return self._reader
|
|
324
|
+
|
|
325
|
+
def _item(self, index: int):
|
|
326
|
+
slot = self._slots[index]
|
|
327
|
+
if slot is STREAMED:
|
|
328
|
+
return self._decode_at(self._random_reader(), index)
|
|
329
|
+
return slot
|
|
330
|
+
|
|
331
|
+
def _iter_positions(self, positions, marked: bool = False):
|
|
332
|
+
"""The records at ``positions`` (ascending, for one handle per
|
|
333
|
+
segment), through one reader opened for this pass. ``marked`` yields
|
|
334
|
+
``(fresh, record)`` pairs, ``fresh`` true for a streamed decode."""
|
|
335
|
+
slots = self._slots
|
|
336
|
+
reader = None
|
|
337
|
+
try:
|
|
338
|
+
for index in positions:
|
|
339
|
+
slot = slots[index]
|
|
340
|
+
if slot is STREAMED:
|
|
341
|
+
if reader is None:
|
|
342
|
+
reader = self._open_reader()
|
|
343
|
+
record = self._decode_at(reader, index)
|
|
344
|
+
yield (True, record) if marked else record
|
|
345
|
+
else:
|
|
346
|
+
yield (False, slot) if marked else slot
|
|
347
|
+
finally:
|
|
348
|
+
if reader is not None:
|
|
349
|
+
reader.close()
|
|
350
|
+
|
|
351
|
+
def iter_marked(self):
|
|
352
|
+
"""Every position as ``(fresh, record)``: ``fresh`` is true when the
|
|
353
|
+
record is a private decode of a streamed slot, false for a retained
|
|
354
|
+
object (which a consumer must not mutate)."""
|
|
355
|
+
return self._iter_positions(range(len(self._slots)), marked=True)
|
|
356
|
+
|
|
357
|
+
def __len__(self) -> int:
|
|
358
|
+
return len(self._slots)
|
|
359
|
+
|
|
360
|
+
def __getitem__(self, index):
|
|
361
|
+
if isinstance(index, slice):
|
|
362
|
+
return _RecordsView(self, range(len(self._slots))[index], ())
|
|
363
|
+
size = len(self._slots)
|
|
364
|
+
if index < 0:
|
|
365
|
+
index += size
|
|
366
|
+
if not 0 <= index < size:
|
|
367
|
+
raise IndexError("retained record index out of range")
|
|
368
|
+
return self._item(index)
|
|
369
|
+
|
|
370
|
+
def __iter__(self):
|
|
371
|
+
return self._iter_positions(range(len(self._slots)))
|
|
372
|
+
|
|
373
|
+
def __setitem__(self, index: int, placeholder) -> None:
|
|
374
|
+
# The digest stays: a `slot_snapshot` shares the digest array and
|
|
375
|
+
# still decodes the original at this position.
|
|
376
|
+
self._slots[index] = placeholder
|
|
377
|
+
|
|
378
|
+
def is_streamed(self, i: int) -> bool:
|
|
379
|
+
return self._slots[i] is STREAMED
|
|
380
|
+
|
|
381
|
+
def slot(self, i: int) -> object:
|
|
382
|
+
"""The raw slot: the retained object, or `STREAMED`."""
|
|
383
|
+
return self._slots[i]
|
|
384
|
+
|
|
385
|
+
def subset(self, positions: "Sequence[int]") -> "Sequence[dict]":
|
|
386
|
+
return _RecordsView(self, array("Q", positions), ())
|
|
387
|
+
|
|
388
|
+
def with_extra(self, obj) -> "Sequence[dict]":
|
|
389
|
+
return _RecordsView(self, range(len(self._slots)), (obj,))
|
|
390
|
+
|
|
391
|
+
def slot_snapshot(self) -> "RetainedRecords":
|
|
392
|
+
"""A copy of the slot list sharing the coordinates and digests."""
|
|
393
|
+
copy = RetainedRecords(self._coords, self._open_reader, self._decode,
|
|
394
|
+
error=self._error)
|
|
395
|
+
copy._slots = list(self._slots)
|
|
396
|
+
copy._digests = self._digests
|
|
397
|
+
copy._normalizer = self._normalizer
|
|
398
|
+
return copy
|
|
399
|
+
|
|
400
|
+
def close(self) -> None:
|
|
401
|
+
"""Release the random-access reader's handles. Iteration passes
|
|
402
|
+
release their own when they end."""
|
|
403
|
+
reader, self._reader = self._reader, None
|
|
404
|
+
if reader is not None:
|
|
405
|
+
reader.close()
|
|
406
|
+
|
|
407
|
+
def __del__(self) -> None:
|
|
408
|
+
# A sequence dropped without `close()` (a preview that is simply
|
|
409
|
+
# discarded) still releases its handle at once.
|
|
410
|
+
try:
|
|
411
|
+
self.close()
|
|
412
|
+
except Exception: # noqa: BLE001 - never raise from a finalizer
|
|
413
|
+
pass
|
|
414
|
+
|
|
415
|
+
def __eq__(self, other):
|
|
416
|
+
return _sequence_eq(self, other)
|
|
417
|
+
|
|
418
|
+
__hash__ = None # type: ignore[assignment]
|
|
419
|
+
|
|
420
|
+
def __repr__(self) -> str:
|
|
421
|
+
return f"<RetainedRecords of {len(self._slots)}>"
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
class _RecordsView(Sequence):
|
|
425
|
+
"""Positions of one `RetainedRecords`, followed by retained extras: a
|
|
426
|
+
slice, a position subset, or the sequence with one object appended."""
|
|
427
|
+
|
|
428
|
+
__slots__ = ("_base", "_positions", "_extras", "__weakref__")
|
|
429
|
+
|
|
430
|
+
def __init__(self, base: RetainedRecords, positions, extras: tuple):
|
|
431
|
+
self._base = base
|
|
432
|
+
self._positions = positions
|
|
433
|
+
self._extras = extras
|
|
434
|
+
|
|
435
|
+
def __len__(self) -> int:
|
|
436
|
+
return len(self._positions) + len(self._extras)
|
|
437
|
+
|
|
438
|
+
def _locate(self, index: int):
|
|
439
|
+
"""``(True, base position)`` or ``(False, extra index)``."""
|
|
440
|
+
size = len(self)
|
|
441
|
+
if index < 0:
|
|
442
|
+
index += size
|
|
443
|
+
if not 0 <= index < size:
|
|
444
|
+
raise IndexError("retained record index out of range")
|
|
445
|
+
count = len(self._positions)
|
|
446
|
+
if index < count:
|
|
447
|
+
return True, self._positions[index]
|
|
448
|
+
return False, index - count
|
|
449
|
+
|
|
450
|
+
def __getitem__(self, index):
|
|
451
|
+
if isinstance(index, slice):
|
|
452
|
+
return self._slice(index)
|
|
453
|
+
in_base, where = self._locate(index)
|
|
454
|
+
if in_base:
|
|
455
|
+
return self._base._item(where)
|
|
456
|
+
return self._extras[where]
|
|
457
|
+
|
|
458
|
+
def _slice(self, index: slice):
|
|
459
|
+
selected = range(len(self))[index]
|
|
460
|
+
count = len(self._positions)
|
|
461
|
+
step = selected.step
|
|
462
|
+
if step < 0:
|
|
463
|
+
return [self[i] for i in selected]
|
|
464
|
+
start, stop = selected.start, selected.stop
|
|
465
|
+
if start < count:
|
|
466
|
+
base_part = self._positions[start:min(stop, count):step]
|
|
467
|
+
first_extra = start + -(-(count - start) // step) * step
|
|
468
|
+
else:
|
|
469
|
+
base_part = self._positions[0:0]
|
|
470
|
+
first_extra = start
|
|
471
|
+
extras = self._extras[first_extra - count:max(stop - count, 0):step]
|
|
472
|
+
return _RecordsView(self._base, base_part, tuple(extras))
|
|
473
|
+
|
|
474
|
+
def __iter__(self):
|
|
475
|
+
yield from self._base._iter_positions(self._positions)
|
|
476
|
+
yield from self._extras
|
|
477
|
+
|
|
478
|
+
def iter_marked(self):
|
|
479
|
+
yield from self._base._iter_positions(self._positions, marked=True)
|
|
480
|
+
for extra in self._extras:
|
|
481
|
+
yield False, extra
|
|
482
|
+
|
|
483
|
+
def is_streamed(self, i: int) -> bool:
|
|
484
|
+
in_base, where = self._locate(i)
|
|
485
|
+
return in_base and self._base.is_streamed(where)
|
|
486
|
+
|
|
487
|
+
def slot(self, i: int) -> object:
|
|
488
|
+
in_base, where = self._locate(i)
|
|
489
|
+
return self._base.slot(where) if in_base else self._extras[where]
|
|
490
|
+
|
|
491
|
+
def subset(self, positions: "Sequence[int]") -> "Sequence[dict]":
|
|
492
|
+
count = len(self._positions)
|
|
493
|
+
base_part = array("Q")
|
|
494
|
+
extras = []
|
|
495
|
+
for i in positions:
|
|
496
|
+
if i < 0 or i >= len(self):
|
|
497
|
+
raise IndexError("retained record index out of range")
|
|
498
|
+
if i < count:
|
|
499
|
+
if extras:
|
|
500
|
+
return [self[j] for j in positions]
|
|
501
|
+
base_part.append(self._positions[i])
|
|
502
|
+
else:
|
|
503
|
+
extras.append(self._extras[i - count])
|
|
504
|
+
return _RecordsView(self._base, base_part, tuple(extras))
|
|
505
|
+
|
|
506
|
+
def with_extra(self, obj) -> "Sequence[dict]":
|
|
507
|
+
return _RecordsView(self._base, self._positions,
|
|
508
|
+
(*self._extras, obj))
|
|
509
|
+
|
|
510
|
+
def __eq__(self, other):
|
|
511
|
+
return _sequence_eq(self, other)
|
|
512
|
+
|
|
513
|
+
__hash__ = None # type: ignore[assignment]
|
|
514
|
+
|
|
515
|
+
def __repr__(self) -> str:
|
|
516
|
+
return f"<RetainedRecords view of {len(self)}>"
|
|
@@ -137,6 +137,14 @@ class SnapshotSignature(NamedTuple):
|
|
|
137
137
|
# referenced snapshot moves no leg above. Empty on a store with no retired
|
|
138
138
|
# snapshot and no unavailable five-hour join, so it is byte-neutral there.
|
|
139
139
|
claude_weekly_retired_digest: str = ""
|
|
140
|
+
# #1050 spec 4.5 item 1: the published leg of stats.db's Claude content
|
|
141
|
+
# revision (`_lib_stats_content_revision.revision_leg`). An in-place
|
|
142
|
+
# rewrite of a Claude-published row (a Model-A convergence, a reviewed
|
|
143
|
+
# rederive, the open-block projection) moves none of the append-shaped
|
|
144
|
+
# legs above; the trigger-maintained revision moves on every one. `""` on a
|
|
145
|
+
# never-mutated, never-repaired, never-published store (byte-neutral), and
|
|
146
|
+
# a fresh `"?"` nonce per read on an untrusted one, so it never idles.
|
|
147
|
+
claude_content_revision: str = ""
|
|
140
148
|
|
|
141
149
|
|
|
142
150
|
def _max_id(conn: sqlite3.Connection, table: str) -> int:
|
|
@@ -254,6 +262,7 @@ def compute_signature(
|
|
|
254
262
|
accounts_digest: str = "",
|
|
255
263
|
claude_stats_digest: str = "",
|
|
256
264
|
claude_weekly_retired_digest: str = "",
|
|
265
|
+
claude_content_revision: str = "",
|
|
257
266
|
) -> SnapshotSignature:
|
|
258
267
|
"""Composite data-version signature across cache.db + stats.db (spec §3).
|
|
259
268
|
|
|
@@ -281,6 +290,7 @@ def compute_signature(
|
|
|
281
290
|
codex_ingest_backlog_sig=_codex_ingest_backlog_sig(cache_conn),
|
|
282
291
|
claude_stats_digest=str(claude_stats_digest),
|
|
283
292
|
claude_weekly_retired_digest=str(claude_weekly_retired_digest),
|
|
293
|
+
claude_content_revision=str(claude_content_revision),
|
|
284
294
|
)
|
|
285
295
|
|
|
286
296
|
|