whykit 0.3.1__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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/parse_cache.py
ADDED
|
@@ -0,0 +1,752 @@
|
|
|
1
|
+
"""Persistent parse cache: skip re-reading unchanged notes on the next run.
|
|
2
|
+
|
|
3
|
+
Every read command parses the whole vault. On a large vault most of that work
|
|
4
|
+
repeats from one run to the next, because most files did not change. This
|
|
5
|
+
module keeps, per note, what WhyKit derives from its text alone (front matter,
|
|
6
|
+
wikilink and Markdown link targets, fact callouts, credential-shaped matches)
|
|
7
|
+
in ``.whykit/cache/`` and reuses it while the file is unchanged. Front matter
|
|
8
|
+
lives in ``notes.v1``; each derived view has a file of its own, read only by a
|
|
9
|
+
command that needs it. Everything
|
|
10
|
+
that depends on more than one file -- link resolution, findings, the graph --
|
|
11
|
+
is still computed on every run.
|
|
12
|
+
|
|
13
|
+
Correctness rules, in order of precedence:
|
|
14
|
+
|
|
15
|
+
* An entry is reused only when the file's ``stat`` signature (modification
|
|
16
|
+
and change time in nanoseconds, size, inode) is exactly the one recorded
|
|
17
|
+
when the entry was parsed. Any difference means a re-parse. The change
|
|
18
|
+
time cannot be set by a user, so neither ``touch -d``/``os.utime`` nor a
|
|
19
|
+
copied-in cache file from another checkout can fake a match. Windows has
|
|
20
|
+
no such change time in ``os.stat`` (``st_ctime`` is the creation time
|
|
21
|
+
there), so on Windows every entry is verified by content.
|
|
22
|
+
* An entry whose file changed within two seconds of being parsed ("racy",
|
|
23
|
+
as Git calls it), or whose timestamps lie in the future, is reused only
|
|
24
|
+
after the file's SHA-256 matches the recorded one. This covers file
|
|
25
|
+
systems with coarse timestamps and clocks that moved.
|
|
26
|
+
* The whole cache is discarded when the WhyKit version, its source code, the
|
|
27
|
+
Python version, the Unicode database or ``whykit.toml`` changes.
|
|
28
|
+
* A cache that cannot be read or does not have the expected shape is ignored
|
|
29
|
+
with a warning and rebuilt. An unreadable or unwritable cache directory
|
|
30
|
+
just means no cache.
|
|
31
|
+
* Credential-shaped matches are stored per note under the same content rule,
|
|
32
|
+
so any change to a note (its ``sensitivity`` included) is scanned again;
|
|
33
|
+
files that are not notes are scanned on every run.
|
|
34
|
+
* Note bodies are never stored. A note's text is read again only if a
|
|
35
|
+
command needs it (full-text search, context packs), and then its SHA-256
|
|
36
|
+
must still match; if it does not, the note is parsed afresh.
|
|
37
|
+
|
|
38
|
+
Writes are atomic (temporary file plus ``os.replace``), take a lock of their
|
|
39
|
+
own and are skipped while another process holds it or while a WhyKit command
|
|
40
|
+
is modifying the vault, so concurrent runs never see a partial file.
|
|
41
|
+
|
|
42
|
+
``--no-cache`` (``whykit --no-cache lint``) or ``WHYKIT_NO_CACHE=1`` turns
|
|
43
|
+
the cache off. Outputs are byte-identical either way.
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
|
|
47
|
+
import contextlib
|
|
48
|
+
import contextvars
|
|
49
|
+
import functools
|
|
50
|
+
import gc
|
|
51
|
+
import hashlib
|
|
52
|
+
import json
|
|
53
|
+
import os
|
|
54
|
+
import secrets
|
|
55
|
+
import stat as stat_mod
|
|
56
|
+
import sys
|
|
57
|
+
import time
|
|
58
|
+
import unicodedata
|
|
59
|
+
from collections.abc import Iterator
|
|
60
|
+
from pathlib import Path
|
|
61
|
+
from typing import Any
|
|
62
|
+
|
|
63
|
+
from . import __version__
|
|
64
|
+
from .config import CONFIG_FILE
|
|
65
|
+
from .console import one_line
|
|
66
|
+
from .lint import Note, _normalise, _shared, _shared_target, load_note
|
|
67
|
+
|
|
68
|
+
CACHE_DIR = Path(".whykit") / "cache"
|
|
69
|
+
# No extension the secret scan or the Markdown collector looks at.
|
|
70
|
+
CACHE_FILE = "notes.v1"
|
|
71
|
+
# File names keep the layout version; FORMAT (stored inside, and part of the
|
|
72
|
+
# fingerprint) is what invalidates entries.
|
|
73
|
+
VIEW_SUFFIX = ".v1"
|
|
74
|
+
LOCK_FILE = "write.lock"
|
|
75
|
+
# Bumped whenever what a parse produces changes shape or meaning (2: link and
|
|
76
|
+
# table parsing became linear, decision and evidence IDs ASCII-digit only, and
|
|
77
|
+
# files that are not valid UTF-8 parse with a finding instead of failing).
|
|
78
|
+
# The fingerprint also hashes the package source, so this is the explicit
|
|
79
|
+
# signal for installed copies whose version string did not move.
|
|
80
|
+
FORMAT = 2
|
|
81
|
+
ENV_DISABLE = "WHYKIT_NO_CACHE"
|
|
82
|
+
RACY_NS = 2_000_000_000
|
|
83
|
+
# Whether ``st_ctime`` is the inode change time, which every content write
|
|
84
|
+
# moves and no user can set. On Windows it is the creation time instead, so a
|
|
85
|
+
# same-size rewrite with the old mtime put back would keep the signature.
|
|
86
|
+
CHANGE_TIME_TRUSTED = os.name != "nt"
|
|
87
|
+
_SEP = os.sep
|
|
88
|
+
|
|
89
|
+
# Derived views of a note's text that the cache stores when a run computed
|
|
90
|
+
# them. Each is a ``functools.cached_property`` on ``Note``; a view a run did
|
|
91
|
+
# not need is simply absent and computed from the text when a later run does.
|
|
92
|
+
NOTE_FACTS: tuple[str, ...] = (
|
|
93
|
+
"cited_evidence",
|
|
94
|
+
"wikilink_hits",
|
|
95
|
+
"markdown_link_hits",
|
|
96
|
+
"fact_callouts",
|
|
97
|
+
"section_decision_id",
|
|
98
|
+
"secret_hits",
|
|
99
|
+
"decision_placeholders",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class _Corrupt(ValueError):
|
|
104
|
+
"""A stored value does not have the shape its view returns."""
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _need(condition: bool) -> None:
|
|
108
|
+
if not condition:
|
|
109
|
+
raise _Corrupt("unexpected value in parse cache")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _int(value: Any) -> int:
|
|
113
|
+
_need(type(value) is int)
|
|
114
|
+
return value
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _str(value: Any) -> str:
|
|
118
|
+
_need(type(value) is str)
|
|
119
|
+
return _shared_target(value)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _bool(value: Any) -> bool:
|
|
123
|
+
_need(type(value) is bool)
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _kind(value: Any) -> str:
|
|
128
|
+
_need(value in ("link", "image"))
|
|
129
|
+
return "image" if value == "image" else "link"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _list(value: Any) -> list:
|
|
133
|
+
_need(type(value) is list)
|
|
134
|
+
return value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _rows(value: Any, width: int) -> list:
|
|
138
|
+
_need(type(value) is list and all(type(row) is list and len(row) == width for row in value))
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# How each stored view turns back into what its ``Note`` property returns.
|
|
143
|
+
# Every conversion checks the shape, so a damaged entry is recomputed from the
|
|
144
|
+
# note's text instead of producing a wrong finding.
|
|
145
|
+
_THAW: dict[str, Any] = {
|
|
146
|
+
"cited_evidence": lambda value: tuple(_str(item) for item in _list(value)),
|
|
147
|
+
"wikilink_hits": lambda value: tuple(
|
|
148
|
+
(_str(target), _int(line), _bool(embed)) for target, line, embed in _rows(value, 3)
|
|
149
|
+
),
|
|
150
|
+
"markdown_link_hits": lambda value: tuple(
|
|
151
|
+
(_kind(kind), _str(target), _int(line)) for kind, target, line in _rows(value, 3)
|
|
152
|
+
),
|
|
153
|
+
"fact_callouts": lambda value: tuple(
|
|
154
|
+
(_int(line), tuple(_str(item) for item in _list(cited))) for line, cited in _rows(value, 2)
|
|
155
|
+
),
|
|
156
|
+
"section_decision_id": lambda value: None if value is None else _str(value),
|
|
157
|
+
"secret_hits": lambda value: tuple((_int(line), _str(label)) for line, label in _rows(value, 2)),
|
|
158
|
+
"decision_placeholders": lambda value: tuple((_int(line), _str(section)) for line, section in _rows(value, 2)),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _thaw_front(value: Any) -> Any:
|
|
163
|
+
"""Check a stored front-matter value and share its short strings, in place.
|
|
164
|
+
|
|
165
|
+
Front matter is made of str, bool, None, lists and one level of mapping;
|
|
166
|
+
JSON round-trips all of them. The decoded containers become the note's
|
|
167
|
+
own, so nothing is copied.
|
|
168
|
+
"""
|
|
169
|
+
kind = type(value)
|
|
170
|
+
if kind is str:
|
|
171
|
+
return _shared(value)
|
|
172
|
+
if kind is list:
|
|
173
|
+
for index, item in enumerate(value):
|
|
174
|
+
value[index] = _shared(item) if type(item) is str else _thaw_front(item)
|
|
175
|
+
return value
|
|
176
|
+
if kind is dict:
|
|
177
|
+
for key, item in value.items():
|
|
178
|
+
value[key] = _shared(item) if type(item) is str else _thaw_front(item)
|
|
179
|
+
return value
|
|
180
|
+
_need(value is None or kind is bool)
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _signature(info: os.stat_result) -> tuple[int, int, int, int]:
|
|
185
|
+
return (info.st_mtime_ns, info.st_ctime_ns, info.st_size, info.st_ino)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _settled(info: os.stat_result, now_ns: int) -> bool:
|
|
189
|
+
"""Whether a file's timestamps are safely in the past at *now_ns*.
|
|
190
|
+
|
|
191
|
+
A write within the same timestamp tick, with the same size, would leave
|
|
192
|
+
the signature unchanged. Two seconds covers the coarsest common
|
|
193
|
+
timestamp resolution (FAT); a timestamp in the future means the clock
|
|
194
|
+
cannot be trusted, so such an entry is always verified by content.
|
|
195
|
+
"""
|
|
196
|
+
if not CHANGE_TIME_TRUSTED:
|
|
197
|
+
return False # without a real change time, only the content proves a match
|
|
198
|
+
newest = max(info.st_mtime_ns, info.st_ctime_ns)
|
|
199
|
+
return now_ns - newest >= RACY_NS
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _decode(data: bytes) -> str:
|
|
203
|
+
# Same text as Path.read_text(encoding="utf-8"): strict UTF-8, universal
|
|
204
|
+
# newlines (load_note translates the line endings of supplied text).
|
|
205
|
+
return data.decode("utf-8")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
_MISSING = object()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class CachedNote(Note):
|
|
212
|
+
"""A note restored from the cache. Its text is read only when used.
|
|
213
|
+
|
|
214
|
+
The text must hash to the digest recorded with the parsed fields. If the
|
|
215
|
+
file changed since the run's ``stat`` (a concurrent edit), the note is
|
|
216
|
+
re-parsed from what is on disk now, as an uncached run reading the file at
|
|
217
|
+
this moment would have done, and it is not written back to the cache.
|
|
218
|
+
|
|
219
|
+
Each stored view is read from its own file the first time a command asks
|
|
220
|
+
for it, so a command that needs only the front matter never loads them.
|
|
221
|
+
"""
|
|
222
|
+
|
|
223
|
+
def __init__(self, path: Path, digest: str, fields: list, cache: DiskCache, key: str) -> None: # noqa: D107
|
|
224
|
+
front, has_front, front_error, body_offset = fields
|
|
225
|
+
self.path = path
|
|
226
|
+
self._text: str | None = None
|
|
227
|
+
self.digest = digest
|
|
228
|
+
self.front = front
|
|
229
|
+
self.has_front = has_front
|
|
230
|
+
self.front_error = front_error
|
|
231
|
+
self.body_offset = body_offset
|
|
232
|
+
self.stale = False
|
|
233
|
+
self.cache = cache
|
|
234
|
+
self.key = key
|
|
235
|
+
|
|
236
|
+
@property # type: ignore[override]
|
|
237
|
+
def text(self) -> str:
|
|
238
|
+
if self._text is None:
|
|
239
|
+
data = self.path.read_bytes()
|
|
240
|
+
if hashlib.sha256(data).hexdigest() == self.digest:
|
|
241
|
+
self._text = _normalise(_decode(data))
|
|
242
|
+
else:
|
|
243
|
+
try:
|
|
244
|
+
fresh = load_note(self.path, text=_decode(data))
|
|
245
|
+
except UnicodeDecodeError:
|
|
246
|
+
fresh = load_note(self.path) # raises like an uncached load
|
|
247
|
+
self.stale = True
|
|
248
|
+
self.cache.stale.add(self.key)
|
|
249
|
+
for name in NOTE_FACTS + ("masked",):
|
|
250
|
+
self.__dict__.pop(name, None)
|
|
251
|
+
self.front = fresh.front
|
|
252
|
+
self.has_front = fresh.has_front
|
|
253
|
+
self.front_error = fresh.front_error
|
|
254
|
+
self.body_offset = fresh.body_offset
|
|
255
|
+
self._text = fresh.text
|
|
256
|
+
return self._text
|
|
257
|
+
|
|
258
|
+
@text.setter
|
|
259
|
+
def text(self, value: str) -> None:
|
|
260
|
+
self._text = value
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
class FreshNote(Note):
|
|
264
|
+
"""A note parsed in this run; views it computes are handed to the cache.
|
|
265
|
+
|
|
266
|
+
The cache keeps the computed values rather than the note, so a command
|
|
267
|
+
that is done with its notes can free them before the cache is written.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
cache: DiskCache
|
|
271
|
+
key: str
|
|
272
|
+
digest: str
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _cached_view(name: str) -> functools.cached_property:
|
|
276
|
+
compute = getattr(Note, name).func
|
|
277
|
+
|
|
278
|
+
def view(self: CachedNote) -> Any:
|
|
279
|
+
if not self.stale:
|
|
280
|
+
raw = self.cache.stored_view(name, self.key, self.digest)
|
|
281
|
+
if raw is not _MISSING:
|
|
282
|
+
try:
|
|
283
|
+
return _THAW[name](raw)
|
|
284
|
+
except (_Corrupt, TypeError, ValueError):
|
|
285
|
+
_warn(f"parse cache entry for {self.path.name} is damaged; recomputing it")
|
|
286
|
+
value = compute(self)
|
|
287
|
+
if not self.stale:
|
|
288
|
+
self.cache.computed(name, self.key, value)
|
|
289
|
+
return value
|
|
290
|
+
|
|
291
|
+
def fresh_view(self: FreshNote) -> Any:
|
|
292
|
+
value = compute(self)
|
|
293
|
+
self.cache.computed(name, self.key, value)
|
|
294
|
+
return value
|
|
295
|
+
|
|
296
|
+
prop = functools.cached_property(view)
|
|
297
|
+
prop.__set_name__(CachedNote, name)
|
|
298
|
+
setattr(CachedNote, name, prop)
|
|
299
|
+
fresh = functools.cached_property(fresh_view)
|
|
300
|
+
fresh.__set_name__(FreshNote, name)
|
|
301
|
+
setattr(FreshNote, name, fresh)
|
|
302
|
+
return prop
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
for _name in NOTE_FACTS:
|
|
306
|
+
_cached_view(_name)
|
|
307
|
+
del _name
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class _Entry:
|
|
311
|
+
__slots__ = ("signature", "digest", "settled", "fields")
|
|
312
|
+
|
|
313
|
+
def __init__(self, signature: tuple[int, int, int, int], digest: str, settled: bool, fields: list) -> None:
|
|
314
|
+
self.signature = signature
|
|
315
|
+
self.digest = digest
|
|
316
|
+
self.settled = settled
|
|
317
|
+
self.fields = fields
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def fingerprint(root: Path) -> str:
|
|
321
|
+
"""Everything outside a note that its cached parse depends on."""
|
|
322
|
+
digest = hashlib.sha256()
|
|
323
|
+
digest.update(f"format={FORMAT}\nversion={__version__}\n".encode())
|
|
324
|
+
digest.update(f"python={sys.version_info[0]}.{sys.version_info[1]}\nunicode={unicodedata.unidata_version}\n".encode())
|
|
325
|
+
package = Path(__file__).resolve().parent
|
|
326
|
+
# The parser's own source: a development checkout changes behaviour without
|
|
327
|
+
# changing the version number. The template supplies placeholder prompts.
|
|
328
|
+
sources = sorted(package.glob("*.py")) + sorted(p for p in (package / "template").rglob("*") if p.is_file())
|
|
329
|
+
for path in sources:
|
|
330
|
+
try:
|
|
331
|
+
data = path.read_bytes()
|
|
332
|
+
except OSError:
|
|
333
|
+
data = b"<unreadable>"
|
|
334
|
+
digest.update(path.relative_to(package).as_posix().encode("utf-8", "surrogateescape"))
|
|
335
|
+
digest.update(hashlib.sha256(data).digest())
|
|
336
|
+
try:
|
|
337
|
+
digest.update(b"config=" + hashlib.sha256((root / CONFIG_FILE).read_bytes()).digest())
|
|
338
|
+
except OSError:
|
|
339
|
+
digest.update(b"config=<none>")
|
|
340
|
+
return digest.hexdigest()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _warn(message: str) -> None:
|
|
344
|
+
try:
|
|
345
|
+
print(f"warning: {one_line(message)}", file=sys.stderr)
|
|
346
|
+
except (OSError, ValueError):
|
|
347
|
+
pass
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _no_symlinks(root: Path, relative: Path) -> bool:
|
|
351
|
+
current = root
|
|
352
|
+
for part in relative.parts:
|
|
353
|
+
current = current / part
|
|
354
|
+
try:
|
|
355
|
+
if stat_mod.S_ISLNK(os.lstat(current).st_mode):
|
|
356
|
+
return False
|
|
357
|
+
except FileNotFoundError:
|
|
358
|
+
return True
|
|
359
|
+
except OSError:
|
|
360
|
+
return False
|
|
361
|
+
return True
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@contextlib.contextmanager
|
|
365
|
+
def _no_gc() -> Iterator[None]:
|
|
366
|
+
"""Pause the cycle collector while building acyclic containers in bulk.
|
|
367
|
+
|
|
368
|
+
Decoding the cache creates hundreds of thousands of lists and dicts; each
|
|
369
|
+
allocation burst would otherwise trigger collections that walk all of them
|
|
370
|
+
again. Nothing created here holds a reference cycle.
|
|
371
|
+
"""
|
|
372
|
+
enabled = gc.isenabled()
|
|
373
|
+
gc.disable()
|
|
374
|
+
try:
|
|
375
|
+
yield
|
|
376
|
+
finally:
|
|
377
|
+
if enabled:
|
|
378
|
+
gc.enable()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _open_nofollow(path: Path, flags: int) -> int:
|
|
382
|
+
"""``os.open`` that refuses to follow a symlink at *path* where supported."""
|
|
383
|
+
return os.open(path, flags | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_BINARY", 0), 0o644)
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
Record = tuple[tuple[int, int, int, int], str, bool]
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _view_tag(digest: str) -> str:
|
|
390
|
+
"""The part of a note's digest a view row carries to say which content it is for.
|
|
391
|
+
|
|
392
|
+
64 bits: the row is only ever compared with the digest of the same path's
|
|
393
|
+
current content, which ``notes.v1`` checked in full.
|
|
394
|
+
"""
|
|
395
|
+
return digest[:16]
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class DiskCache:
|
|
399
|
+
"""The parse cache of one vault for the duration of one command.
|
|
400
|
+
|
|
401
|
+
``notes.v1`` holds each note's ``stat`` signature, digest and front
|
|
402
|
+
matter. Each derived view has a file of its own (``wikilink_hits.v1`` and
|
|
403
|
+
so on), keyed by the same digest and read only when a command first asks
|
|
404
|
+
for that view. Any of them can be missing or stale without affecting the
|
|
405
|
+
others.
|
|
406
|
+
|
|
407
|
+
``notes.v1`` is written as soon as the notes are loaded, when it changed;
|
|
408
|
+
the view files when the command ends, from the views it computed. The
|
|
409
|
+
cache never keeps the notes themselves alive.
|
|
410
|
+
"""
|
|
411
|
+
|
|
412
|
+
def __init__(self, root: Path) -> None:
|
|
413
|
+
self.root = root
|
|
414
|
+
self.directory = root / CACHE_DIR
|
|
415
|
+
self.file = self.directory / CACHE_FILE
|
|
416
|
+
self.fingerprint = fingerprint(root)
|
|
417
|
+
self.entries: dict[str, _Entry] = {}
|
|
418
|
+
# Digest of each note the last load recorded, for the view files.
|
|
419
|
+
self.digests: dict[str, str] | None = None
|
|
420
|
+
self.stale: set[str] = set()
|
|
421
|
+
self.dirty = False
|
|
422
|
+
self.hits = 0
|
|
423
|
+
self.misses = 0
|
|
424
|
+
self._views: dict[str, dict[str, Any]] = {}
|
|
425
|
+
# Views whose file must be rewritten, with the values this run computed.
|
|
426
|
+
self._computed: dict[str, dict[str, Any]] = {}
|
|
427
|
+
self._broken_views: set[str] = set()
|
|
428
|
+
self._read()
|
|
429
|
+
|
|
430
|
+
# -- reading ---------------------------------------------------------
|
|
431
|
+
|
|
432
|
+
def _load_json(self, path: Path) -> Any:
|
|
433
|
+
"""The decoded document at *path*, or ``None`` when there is none to trust."""
|
|
434
|
+
if not _no_symlinks(self.root, path.relative_to(self.root)):
|
|
435
|
+
return None
|
|
436
|
+
try:
|
|
437
|
+
info = os.stat(path)
|
|
438
|
+
except OSError:
|
|
439
|
+
return None
|
|
440
|
+
# Only trust a cache this user wrote.
|
|
441
|
+
if hasattr(os, "getuid") and info.st_uid != os.getuid():
|
|
442
|
+
return None
|
|
443
|
+
try:
|
|
444
|
+
with open(path, "rb") as handle:
|
|
445
|
+
raw = handle.read()
|
|
446
|
+
except OSError:
|
|
447
|
+
return None
|
|
448
|
+
with _no_gc():
|
|
449
|
+
data = json.loads(raw)
|
|
450
|
+
if not isinstance(data, dict) or data.get("format") != FORMAT:
|
|
451
|
+
raise ValueError("unknown format")
|
|
452
|
+
if data.get("fingerprint") != self.fingerprint:
|
|
453
|
+
return None # WhyKit, Python or whykit.toml changed
|
|
454
|
+
return data
|
|
455
|
+
|
|
456
|
+
def _read(self) -> None:
|
|
457
|
+
try:
|
|
458
|
+
data = self._load_json(self.file)
|
|
459
|
+
if data is None:
|
|
460
|
+
self.dirty = os.path.lexists(self.file)
|
|
461
|
+
return
|
|
462
|
+
entries: dict[str, _Entry] = {}
|
|
463
|
+
items = data["entries"]
|
|
464
|
+
if not isinstance(items, dict):
|
|
465
|
+
raise ValueError("entries is not an object")
|
|
466
|
+
for key, row in items.items():
|
|
467
|
+
mtime, ctime, size, ino, digest, settled, fields = row
|
|
468
|
+
if not (
|
|
469
|
+
all(type(value) is int for value in (mtime, ctime, size, ino))
|
|
470
|
+
and isinstance(digest, str) and len(digest) == 64
|
|
471
|
+
and type(settled) is bool
|
|
472
|
+
and isinstance(fields, list) and len(fields) == 4
|
|
473
|
+
and isinstance(fields[0], dict) and type(fields[1]) is bool
|
|
474
|
+
and (fields[2] is None or isinstance(fields[2], str)) and type(fields[3]) is int
|
|
475
|
+
):
|
|
476
|
+
raise ValueError(f"malformed entry for {key!r}")
|
|
477
|
+
entries[key] = _Entry((mtime, ctime, size, ino), digest, settled, fields)
|
|
478
|
+
except (ValueError, TypeError, KeyError, RecursionError) as exc:
|
|
479
|
+
_warn(f"ignoring unreadable parse cache {self.file} ({exc}); it will be rebuilt")
|
|
480
|
+
self.dirty = True
|
|
481
|
+
return
|
|
482
|
+
self.entries = entries
|
|
483
|
+
|
|
484
|
+
def _view_file(self, name: str) -> Path:
|
|
485
|
+
return self.directory / f"{name}{VIEW_SUFFIX}"
|
|
486
|
+
|
|
487
|
+
def _decode_view(self, name: str) -> dict[str, Any]:
|
|
488
|
+
path = self._view_file(name)
|
|
489
|
+
try:
|
|
490
|
+
data = self._load_json(path)
|
|
491
|
+
if data is None:
|
|
492
|
+
if os.path.lexists(path):
|
|
493
|
+
self._broken_views.add(name) # from another WhyKit or config
|
|
494
|
+
return {}
|
|
495
|
+
rows = data["rows"]
|
|
496
|
+
if not isinstance(rows, dict):
|
|
497
|
+
raise ValueError("rows is not an object")
|
|
498
|
+
return rows
|
|
499
|
+
except (ValueError, TypeError, KeyError, RecursionError) as exc:
|
|
500
|
+
if name not in self._broken_views:
|
|
501
|
+
_warn(f"ignoring unreadable parse cache {path} ({exc}); it will be rebuilt")
|
|
502
|
+
self._broken_views.add(name)
|
|
503
|
+
return {}
|
|
504
|
+
|
|
505
|
+
def stored_view(self, name: str, key: str, digest: str) -> Any:
|
|
506
|
+
"""The stored value of view *name* for the note at *key*, if it belongs to *digest*.
|
|
507
|
+
|
|
508
|
+
The row is handed over (removed): the note keeps the converted value,
|
|
509
|
+
and holding both would double the memory.
|
|
510
|
+
"""
|
|
511
|
+
rows = self._views.get(name)
|
|
512
|
+
if rows is None:
|
|
513
|
+
rows = self._views[name] = self._decode_view(name)
|
|
514
|
+
row = rows.pop(key, None)
|
|
515
|
+
if type(row) is list and len(row) == 2 and row[0] == _view_tag(digest):
|
|
516
|
+
return row[1]
|
|
517
|
+
return _MISSING
|
|
518
|
+
|
|
519
|
+
def computed(self, name: str, key: str, value: Any) -> None:
|
|
520
|
+
"""Record a view this run computed because the cache did not have it."""
|
|
521
|
+
self._computed.setdefault(name, {})[key] = value
|
|
522
|
+
|
|
523
|
+
def _key(self, path: Path) -> str:
|
|
524
|
+
"""The note's vault-relative path, always with ``/`` separators.
|
|
525
|
+
|
|
526
|
+
One spelling on every platform, so a cache written on Windows is keyed
|
|
527
|
+
like every other record of the vault (``01-strategy/note.md``).
|
|
528
|
+
"""
|
|
529
|
+
text = os.fspath(path)
|
|
530
|
+
prefix = os.fspath(self.root).rstrip(_SEP) + _SEP
|
|
531
|
+
key = text[len(prefix):] if text.startswith(prefix) else text
|
|
532
|
+
return key if _SEP == "/" else key.replace(_SEP, "/")
|
|
533
|
+
|
|
534
|
+
def load_all(self, paths: list[Path]) -> list[Note]:
|
|
535
|
+
"""Parse *paths* like ``load_note`` would, reusing unchanged entries."""
|
|
536
|
+
if self.digests is not None:
|
|
537
|
+
# A second load in the same command: start from what is on disk now.
|
|
538
|
+
self.dirty = False
|
|
539
|
+
self._read()
|
|
540
|
+
notes: list[Note] = []
|
|
541
|
+
rows: dict[str, list] = {}
|
|
542
|
+
with _no_gc():
|
|
543
|
+
for path in paths:
|
|
544
|
+
key = self._key(path)
|
|
545
|
+
note, record = self._load(path, key)
|
|
546
|
+
notes.append(note)
|
|
547
|
+
if record is not None:
|
|
548
|
+
signature, digest, settled = record
|
|
549
|
+
rows[key] = [*signature, digest, settled, [note.front, note.has_front, note.front_error, note.body_offset]]
|
|
550
|
+
if set(self.entries) - set(rows):
|
|
551
|
+
self.dirty = True # a note was deleted, renamed or could not be recorded
|
|
552
|
+
if self.dirty:
|
|
553
|
+
self._write([(self.file, {"format": FORMAT, "whykit": __version__, "fingerprint": self.fingerprint, "entries": rows})])
|
|
554
|
+
# The notes own their front matter now; keep only what the view files need.
|
|
555
|
+
self.entries = {}
|
|
556
|
+
self.digests = {key: row[4] for key, row in rows.items()}
|
|
557
|
+
return notes
|
|
558
|
+
|
|
559
|
+
def _load(self, path: Path, key: str) -> tuple[Note, Record | None]:
|
|
560
|
+
try:
|
|
561
|
+
before = os.stat(path)
|
|
562
|
+
except OSError:
|
|
563
|
+
return load_note(path), None # raises like an uncached load
|
|
564
|
+
signature = _signature(before)
|
|
565
|
+
entry = self.entries.get(key)
|
|
566
|
+
now = time.time_ns()
|
|
567
|
+
if entry is not None and entry.signature == signature:
|
|
568
|
+
if entry.settled:
|
|
569
|
+
note = self._restore(path, key, entry, entry.digest)
|
|
570
|
+
if note is not None:
|
|
571
|
+
return note, (signature, entry.digest, True)
|
|
572
|
+
return self._parse(path, key, path.read_bytes(), before, now)
|
|
573
|
+
# Racy or future-dated when it was parsed: verify by content.
|
|
574
|
+
data = path.read_bytes()
|
|
575
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
576
|
+
if digest == entry.digest and _signature(os.stat(path)) == signature:
|
|
577
|
+
note = self._restore(path, key, entry, digest)
|
|
578
|
+
if note is not None:
|
|
579
|
+
note.text = _normalise(_decode(data))
|
|
580
|
+
settled = _settled(before, now)
|
|
581
|
+
if settled:
|
|
582
|
+
self.dirty = True
|
|
583
|
+
return note, (signature, digest, settled)
|
|
584
|
+
return self._parse(path, key, data, before, now)
|
|
585
|
+
return self._parse(path, key, path.read_bytes(), before, now)
|
|
586
|
+
|
|
587
|
+
def _restore(self, path: Path, key: str, entry: _Entry, digest: str) -> CachedNote | None:
|
|
588
|
+
try:
|
|
589
|
+
front = _thaw_front(entry.fields[0])
|
|
590
|
+
except (_Corrupt, TypeError, ValueError, RecursionError):
|
|
591
|
+
_warn(f"parse cache entry for {path.name} is damaged; parsing the note again")
|
|
592
|
+
return None
|
|
593
|
+
self.hits += 1
|
|
594
|
+
return CachedNote(path, digest, [front, *entry.fields[1:]], self, key)
|
|
595
|
+
|
|
596
|
+
def _parse(self, path: Path, key: str, data: bytes, before: os.stat_result, now: int) -> tuple[Note, Record | None]:
|
|
597
|
+
self.misses += 1
|
|
598
|
+
self.dirty = True
|
|
599
|
+
try:
|
|
600
|
+
text = _decode(data)
|
|
601
|
+
except UnicodeDecodeError:
|
|
602
|
+
return load_note(path), None # raises exactly like an uncached load
|
|
603
|
+
note = load_note(path, text=text)
|
|
604
|
+
try:
|
|
605
|
+
after = os.stat(path)
|
|
606
|
+
except OSError:
|
|
607
|
+
return note, None
|
|
608
|
+
if _signature(after) != _signature(before):
|
|
609
|
+
return note, None # changed while being read: do not record
|
|
610
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
611
|
+
note.__class__ = FreshNote
|
|
612
|
+
note.cache, note.key, note.digest = self, key, digest # type: ignore[attr-defined]
|
|
613
|
+
return note, (_signature(before), digest, _settled(before, now))
|
|
614
|
+
|
|
615
|
+
# -- writing ---------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
def _view_payloads(self) -> Iterator[tuple[Path, dict]]:
|
|
618
|
+
if self.digests is None:
|
|
619
|
+
return
|
|
620
|
+
current = {key: _view_tag(digest) for key, digest in self.digests.items() if key not in self.stale}
|
|
621
|
+
for name in NOTE_FACTS:
|
|
622
|
+
computed = self._computed.get(name, {})
|
|
623
|
+
if not computed and name not in self._broken_views:
|
|
624
|
+
continue
|
|
625
|
+
# Start from what is on disk (decoded again: rows handed to notes
|
|
626
|
+
# are gone from the copy in memory), keep what still matches.
|
|
627
|
+
stored = {} if name in self._broken_views else self._decode_view(name)
|
|
628
|
+
rows: dict[str, list] = {}
|
|
629
|
+
for key, tag in current.items():
|
|
630
|
+
if key in computed:
|
|
631
|
+
rows[key] = [tag, computed[key]]
|
|
632
|
+
else:
|
|
633
|
+
row = stored.get(key)
|
|
634
|
+
if type(row) is list and len(row) == 2 and row[0] == tag:
|
|
635
|
+
rows[key] = row
|
|
636
|
+
yield self._view_file(name), {"format": FORMAT, "whykit": __version__, "fingerprint": self.fingerprint, "rows": rows}
|
|
637
|
+
|
|
638
|
+
def save(self) -> None:
|
|
639
|
+
"""Write the view files this run added to or found damaged."""
|
|
640
|
+
self._write(list(self._view_payloads()))
|
|
641
|
+
|
|
642
|
+
def _write(self, payloads: list[tuple[Path, dict]]) -> None:
|
|
643
|
+
if not payloads:
|
|
644
|
+
return
|
|
645
|
+
try:
|
|
646
|
+
from .io import _acquire_exclusive, _release_exclusive, safe_vault_dir
|
|
647
|
+
|
|
648
|
+
if not _no_symlinks(self.root, CACHE_DIR):
|
|
649
|
+
return
|
|
650
|
+
directory = safe_vault_dir(self.root, CACHE_DIR)
|
|
651
|
+
ignore = directory / ".gitignore"
|
|
652
|
+
if not os.path.lexists(ignore):
|
|
653
|
+
# Older vaults do not ignore the cache; keep it out of Git anyway.
|
|
654
|
+
with contextlib.suppress(FileExistsError):
|
|
655
|
+
fd = _open_nofollow(ignore, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
|
|
656
|
+
with os.fdopen(fd, "w", encoding="utf-8") as out:
|
|
657
|
+
out.write("# WhyKit parse cache: machine state, safe to delete.\n*\n")
|
|
658
|
+
blobs = [
|
|
659
|
+
(target, json.dumps(payload, ensure_ascii=True, separators=(",", ":")).encode("ascii"))
|
|
660
|
+
for target, payload in payloads
|
|
661
|
+
]
|
|
662
|
+
except (OSError, RuntimeError, ValueError):
|
|
663
|
+
return
|
|
664
|
+
with contextlib.ExitStack() as stack:
|
|
665
|
+
try:
|
|
666
|
+
handle = stack.enter_context(os.fdopen(_open_nofollow(directory / LOCK_FILE, os.O_RDWR | os.O_CREAT), "r+b"))
|
|
667
|
+
_acquire_exclusive(handle, non_blocking=True)
|
|
668
|
+
stack.callback(_release_exclusive, handle)
|
|
669
|
+
except OSError:
|
|
670
|
+
return # another run is writing the cache; its copy is as good
|
|
671
|
+
if self._vault_busy():
|
|
672
|
+
return
|
|
673
|
+
for target, blob in blobs:
|
|
674
|
+
tmp = directory / f".{target.name}.tmp-{secrets.token_hex(6)}"
|
|
675
|
+
try:
|
|
676
|
+
with open(tmp, "xb") as out:
|
|
677
|
+
out.write(blob)
|
|
678
|
+
out.flush()
|
|
679
|
+
os.fsync(out.fileno())
|
|
680
|
+
os.replace(tmp, target)
|
|
681
|
+
except OSError:
|
|
682
|
+
with contextlib.suppress(OSError):
|
|
683
|
+
tmp.unlink()
|
|
684
|
+
return
|
|
685
|
+
|
|
686
|
+
def _vault_busy(self) -> bool:
|
|
687
|
+
"""True while another WhyKit command holds the vault's mutation lock."""
|
|
688
|
+
from .io import _acquire_exclusive, _release_exclusive
|
|
689
|
+
|
|
690
|
+
lock = self.root / ".whykit" / "mutation.lock"
|
|
691
|
+
try:
|
|
692
|
+
handle = os.fdopen(_open_nofollow(lock, os.O_RDWR), "r+b")
|
|
693
|
+
except OSError:
|
|
694
|
+
return False
|
|
695
|
+
with handle:
|
|
696
|
+
try:
|
|
697
|
+
_acquire_exclusive(handle, non_blocking=True)
|
|
698
|
+
except OSError:
|
|
699
|
+
return True
|
|
700
|
+
with contextlib.suppress(OSError):
|
|
701
|
+
_release_exclusive(handle)
|
|
702
|
+
return False
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
# -- scope -------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
class _Scope:
|
|
708
|
+
def __init__(self) -> None:
|
|
709
|
+
self.caches: dict[str, DiskCache | None] = {}
|
|
710
|
+
|
|
711
|
+
def cache_for(self, root: Path) -> DiskCache | None:
|
|
712
|
+
key = os.fspath(root)
|
|
713
|
+
if key not in self.caches:
|
|
714
|
+
try:
|
|
715
|
+
self.caches[key] = DiskCache(root)
|
|
716
|
+
except OSError:
|
|
717
|
+
self.caches[key] = None
|
|
718
|
+
return self.caches[key]
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
_SCOPE: contextvars.ContextVar[_Scope | None] = contextvars.ContextVar("whykit_parse_cache", default=None)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def disabled_by_environment() -> bool:
|
|
725
|
+
return os.environ.get(ENV_DISABLE, "").strip().lower() not in ("", "0", "false", "no", "off")
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def current(root: Path) -> DiskCache | None:
|
|
729
|
+
"""The cache for *root* if a :func:`persistent` scope is active."""
|
|
730
|
+
scope = _SCOPE.get()
|
|
731
|
+
return scope.cache_for(root) if scope is not None else None
|
|
732
|
+
|
|
733
|
+
|
|
734
|
+
@contextlib.contextmanager
|
|
735
|
+
def persistent(enabled: bool = True) -> Iterator[None]:
|
|
736
|
+
"""Use and update the on-disk parse cache for every vault loaded inside.
|
|
737
|
+
|
|
738
|
+
Front matter and signatures are written when the vault is loaded; views
|
|
739
|
+
computed along the way when the block finishes without an exception.
|
|
740
|
+
"""
|
|
741
|
+
if not enabled or disabled_by_environment() or _SCOPE.get() is not None:
|
|
742
|
+
yield
|
|
743
|
+
return
|
|
744
|
+
scope = _Scope()
|
|
745
|
+
token = _SCOPE.set(scope)
|
|
746
|
+
try:
|
|
747
|
+
yield
|
|
748
|
+
finally:
|
|
749
|
+
_SCOPE.reset(token)
|
|
750
|
+
for cache in scope.caches.values():
|
|
751
|
+
if cache is not None:
|
|
752
|
+
cache.save()
|