flyleaf 0.5.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.
flyleaf/__init__.py ADDED
@@ -0,0 +1,11 @@
1
+ # Copyright 2026 Krishna Dahale
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Inventory AI components in a repository and list areas to review."""
4
+
5
+ __version__ = "0.5.0"
6
+
7
+ DISCLAIMER = (
8
+ "flyleaf lists AI components and areas to review. "
9
+ "Legal classification under the EU AI Act depends on the use case, "
10
+ "which code cannot determine. This output is not legal advice."
11
+ )
flyleaf/attribution.py ADDED
@@ -0,0 +1,96 @@
1
+ # Copyright 2026 Krishna Dahale
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Who should look at a finding.
4
+
5
+ Two different names, deliberately kept apart. The owner is declared in
6
+ `.flyleaf/systems.toml` and is accountable for the system. The author is read
7
+ from git blame and is whoever last touched the evidence line. The author is
8
+ the person who can answer the question now. The owner is the person who is
9
+ answerable for it either way.
10
+
11
+ Blame names a line, not a responsibility. A reformat, a rename, or a bot
12
+ bumping a version will make the wrong person the author, so the report says
13
+ where the name came from and never calls it fault.
14
+ """
15
+
16
+ import re
17
+ import subprocess
18
+ from pathlib import Path
19
+
20
+ BOT_PATTERN = re.compile(r"(?i)\[bot\]|^(dependabot|renovate|github-actions)\b")
21
+ _EMPTY_SHA = "0" * 40
22
+
23
+
24
+ def last_author(repo: Path, rev: str | None, path: str, line: int) -> dict | None:
25
+ """Return who last changed `line` of `path`, or None when git cannot say.
26
+
27
+ `rev` blames at a revision. None blames the working tree, where an
28
+ uncommitted edit has no author yet.
29
+ """
30
+ args = ["blame", "--porcelain", "-L", f"{max(1, line)},{max(1, line)}"]
31
+ if rev:
32
+ args.append(rev)
33
+ args.extend(["--", path])
34
+ try:
35
+ completed = subprocess.run(
36
+ ["git", "-C", str(repo), *args],
37
+ check=True,
38
+ capture_output=True,
39
+ text=True,
40
+ )
41
+ except (OSError, subprocess.CalledProcessError):
42
+ return None
43
+ return _parse(completed.stdout)
44
+
45
+
46
+ def _parse(output: str) -> dict | None:
47
+ lines = output.splitlines()
48
+ if not lines:
49
+ return None
50
+ commit = lines[0].split(" ", 1)[0]
51
+ fields: dict[str, str] = {}
52
+ for raw in lines[1:]:
53
+ if raw.startswith("\t"):
54
+ break
55
+ key, _, value = raw.partition(" ")
56
+ fields.setdefault(key, value)
57
+
58
+ name = fields.get("author", "").strip()
59
+ email = fields.get("author-mail", "").strip().strip("<>")
60
+ uncommitted = commit == _EMPTY_SHA
61
+ if uncommitted:
62
+ return {
63
+ "name": None,
64
+ "email": None,
65
+ "commit": None,
66
+ "summary": None,
67
+ "uncommitted": True,
68
+ "is_bot": False,
69
+ "source": "git blame",
70
+ }
71
+ if not name and not email:
72
+ return None
73
+ return {
74
+ "name": name or None,
75
+ "email": email or None,
76
+ "commit": commit[:12],
77
+ "summary": fields.get("summary") or None,
78
+ "uncommitted": False,
79
+ "is_bot": _is_bot(name, email),
80
+ "source": "git blame",
81
+ }
82
+
83
+
84
+ def _is_bot(name: str, email: str) -> bool:
85
+ return bool(BOT_PATTERN.search(name or "") or BOT_PATTERN.search(email or ""))
86
+
87
+
88
+ def label(author: dict | None) -> str:
89
+ """A short human label for a report line."""
90
+ if author is None:
91
+ return "unknown"
92
+ if author["uncommitted"]:
93
+ return "uncommitted change"
94
+ who = author["email"] or author["name"] or "unknown"
95
+ suffix = " (bot)" if author["is_bot"] else ""
96
+ return f"{who}{suffix}"
flyleaf/baseline.py ADDED
@@ -0,0 +1,66 @@
1
+ # Copyright 2026 Krishna Dahale
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """A baseline is the inventory as it stood at the last sign-off."""
4
+
5
+ import json
6
+ from datetime import UTC, date, datetime
7
+ from pathlib import Path
8
+
9
+ from flyleaf import __version__
10
+ from flyleaf.scan import scan_path
11
+
12
+ BASELINE_FILE = Path(".flyleaf") / "baseline.json"
13
+
14
+
15
+ class BaselineError(Exception):
16
+ """The baseline could not be read."""
17
+
18
+
19
+ def baseline_path(repo: Path) -> Path:
20
+ return repo / BASELINE_FILE
21
+
22
+
23
+ def write_baseline(repo: Path, rev: str | None, approved_by: str | None) -> Path:
24
+ """Record the current tree as the approved state."""
25
+ inventory = scan_path(repo)
26
+ document = {
27
+ "kind": "baseline",
28
+ "tool": {"name": "flyleaf", "version": __version__},
29
+ "recorded_at": datetime.now(UTC).date().isoformat(),
30
+ "rev": rev,
31
+ "approved_by": approved_by,
32
+ "inventory": inventory,
33
+ }
34
+ destination = baseline_path(repo)
35
+ destination.parent.mkdir(parents=True, exist_ok=True)
36
+ destination.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
37
+ return destination
38
+
39
+
40
+ def read_baseline(repo: Path) -> dict:
41
+ path = baseline_path(repo)
42
+ if not path.is_file():
43
+ raise BaselineError(
44
+ f"No baseline at {BASELINE_FILE.as_posix()}. Run 'flyleaf baseline' to record one."
45
+ )
46
+ try:
47
+ document = json.loads(path.read_text(encoding="utf-8"))
48
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
49
+ raise BaselineError(f"Could not read {BASELINE_FILE.as_posix()}: {exc}") from exc
50
+ inventory = document.get("inventory")
51
+ if not isinstance(inventory, dict) or "components" not in inventory:
52
+ raise BaselineError(f"{BASELINE_FILE.as_posix()} does not contain an inventory.")
53
+ return document
54
+
55
+
56
+ def describe(document: dict) -> dict:
57
+ return {
58
+ "ref": "baseline",
59
+ "rev": document.get("rev"),
60
+ "recorded_at": document.get("recorded_at"),
61
+ "approved_by": document.get("approved_by"),
62
+ }
63
+
64
+
65
+ def today() -> date:
66
+ return datetime.now(UTC).date()
flyleaf/brief.py ADDED
@@ -0,0 +1,427 @@
1
+ # Copyright 2026 Krishna Dahale
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Compare two inventories and list documentation changes with citations."""
4
+
5
+ import io
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tarfile
10
+ import tempfile
11
+ from datetime import date
12
+ from pathlib import Path
13
+
14
+ from flyleaf import DISCLAIMER, __version__, attribution
15
+ from flyleaf import baseline as baseline_store
16
+ from flyleaf import severity as severity_grades
17
+ from flyleaf import waivers as waiver_store
18
+ from flyleaf.citations import pack_meta, payloads_for
19
+ from flyleaf.scan import SCHEMA_VERSION, scan_path
20
+
21
+
22
+ class GitError(Exception):
23
+ """The repository could not be read with git."""
24
+
25
+
26
+ def build_brief(
27
+ start: Path,
28
+ base: str | None,
29
+ head: str | None,
30
+ use_baseline: bool = False,
31
+ today: date | None = None,
32
+ blame: bool = True,
33
+ ) -> dict:
34
+ """Diff a base state against a head state.
35
+
36
+ The base is a git ref, or the recorded baseline when `use_baseline` is set.
37
+ The head is a git ref, or the working tree when `head` is omitted.
38
+ """
39
+ if not start.exists():
40
+ raise FileNotFoundError(start)
41
+ repo = _git_root(start)
42
+ now = today or baseline_store.today()
43
+
44
+ if use_baseline:
45
+ document = baseline_store.read_baseline(repo)
46
+ base_inventory = document["inventory"]
47
+ base_meta = baseline_store.describe(document)
48
+ else:
49
+ base_rev = _rev_parse(repo, base or "HEAD")
50
+ base_inventory = _scan_ref(repo, base_rev)
51
+ base_meta = {"ref": base, "rev": base_rev}
52
+
53
+ if head is None:
54
+ head_meta = {
55
+ "ref": "working tree",
56
+ "rev": _rev_parse(repo, "HEAD"),
57
+ "dirty": bool(_git(repo, "status", "--porcelain")),
58
+ }
59
+ head_inventory = scan_path(repo)
60
+ else:
61
+ head_rev = _rev_parse(repo, head)
62
+ head_meta = {"ref": head, "rev": head_rev, "dirty": False}
63
+ head_inventory = _scan_ref(repo, head_rev)
64
+
65
+ found = _diff(base_inventory, head_inventory)
66
+ _attribute(repo, found, head_inventory, head_meta, base_meta, blame)
67
+
68
+ loaded, warnings = waiver_store.load_waivers(repo)
69
+ findings, waived = _partition(found, loaded, now)
70
+ findings, system_waived = _partition(_rollup(findings), loaded, now)
71
+ waived.extend(system_waived)
72
+ warnings.extend(_expiry_warnings(waived, now))
73
+
74
+ citation_ids: set[str] = set()
75
+ for finding in findings + waived:
76
+ citation_ids.update(finding["citation_ids"])
77
+
78
+ return {
79
+ "schema_version": SCHEMA_VERSION,
80
+ "kind": "brief",
81
+ "tool": {"name": "flyleaf", "version": __version__},
82
+ "disclaimer": DISCLAIMER,
83
+ "severity_note": severity_grades.SEVERITY_NOTE,
84
+ "citation_pack": pack_meta(),
85
+ "citations": payloads_for(citation_ids),
86
+ "repository": str(repo),
87
+ "as_of": now.isoformat(),
88
+ "base": base_meta,
89
+ "head": head_meta,
90
+ "systems": head_inventory.get("systems", []),
91
+ "findings": findings,
92
+ "waived": waived,
93
+ "warnings": warnings,
94
+ }
95
+
96
+
97
+ def _attribute(
98
+ repo: Path,
99
+ findings: list[dict],
100
+ head_inventory: dict,
101
+ head_meta: dict,
102
+ base_meta: dict,
103
+ blame: bool,
104
+ ) -> None:
105
+ """Put a name on every finding, from the declared owner and from git blame."""
106
+ owners = {
107
+ system["name"]: system.get("owner")
108
+ for system in head_inventory.get("systems") or []
109
+ }
110
+ head_rev = None if head_meta["ref"] == "working tree" else head_meta["rev"]
111
+ for finding in findings:
112
+ finding["owner"] = owners.get(finding.get("system") or "")
113
+ if not blame:
114
+ finding["author"] = None
115
+ continue
116
+ # A removed component is not in the head tree, so blame the base.
117
+ rev = base_meta.get("rev") if finding["change"] == "removed" else head_rev
118
+ finding["author"] = attribution.last_author(repo, rev, finding["path"], finding["line"])
119
+
120
+
121
+ def highest_severity(brief: dict) -> str | None:
122
+ """The worst severity among active findings, or None when the brief is quiet."""
123
+ worst: str | None = None
124
+ for finding in brief["findings"]:
125
+ if worst is None or severity_grades.at_least(finding["severity"], worst):
126
+ worst = finding["severity"]
127
+ return worst
128
+
129
+
130
+ def _partition(
131
+ findings: list[dict],
132
+ waivers: list[waiver_store.Waiver],
133
+ today: date,
134
+ ) -> tuple[list[dict], list[dict]]:
135
+ """Split findings into active and waived.
136
+
137
+ An expired waiver does not suppress. The finding returns for review and
138
+ says which waiver lapsed.
139
+ """
140
+ active: list[dict] = []
141
+ waived: list[dict] = []
142
+ for finding in findings:
143
+ waiver = waiver_store.match(waivers, finding["component_id"], finding["change"])
144
+ if waiver is None:
145
+ active.append(finding)
146
+ continue
147
+ payload = waiver.payload(today)
148
+ if waiver.days_left(today) < 0:
149
+ finding["waiver"] = payload
150
+ finding["waiver_state"] = "expired"
151
+ finding["summary"] = (
152
+ f"{finding['summary']} A waiver by {waiver.approved_by} expired on "
153
+ f"{waiver.expires.isoformat()}, so this is open again."
154
+ )
155
+ active.append(finding)
156
+ else:
157
+ finding["waiver"] = payload
158
+ finding["waiver_state"] = "active"
159
+ waived.append(finding)
160
+ return active, waived
161
+
162
+
163
+ def _expiry_warnings(waived: list[dict], today: date) -> list[str]:
164
+ warnings: list[str] = []
165
+ for finding in waived:
166
+ waiver = finding["waiver"]
167
+ days = waiver["days_left"]
168
+ if days <= waiver_store.EXPIRY_WARNING_DAYS:
169
+ warnings.append(
170
+ f"Waiver for {finding['component_id']} expires on {waiver['expires']} "
171
+ f"in {days} day(s). Renew it or close the finding."
172
+ )
173
+ return warnings
174
+
175
+
176
+ def _diff(base_inventory: dict, head_inventory: dict) -> list[dict]:
177
+ base_map = {component["id"]: component for component in base_inventory["components"]}
178
+ head_map = {component["id"]: component for component in head_inventory["components"]}
179
+ findings: list[dict] = []
180
+ for component_id in sorted(set(base_map) | set(head_map)):
181
+ before = base_map.get(component_id)
182
+ after = head_map.get(component_id)
183
+ if before is None and after is not None:
184
+ findings.append(_added(after))
185
+ elif after is None and before is not None:
186
+ findings.append(_removed(before))
187
+ elif before is not None and after is not None:
188
+ findings.extend(_changed(before, after))
189
+ return _sorted(findings)
190
+
191
+
192
+ def _sorted(findings: list[dict]) -> list[dict]:
193
+ return sorted(
194
+ findings,
195
+ key=lambda item: (-severity_grades.rank(item["severity"]), item["path"], item["framework"]),
196
+ )
197
+
198
+
199
+ def _rollup(findings: list[dict]) -> list[dict]:
200
+ """Collapse the findings of a declared system into one.
201
+
202
+ Components are the evidence. A system is what a person documents, so when
203
+ the repository declares one, the system is what the brief reports.
204
+ """
205
+ grouped: dict[str, list[dict]] = {}
206
+ standalone: list[dict] = []
207
+ for finding in findings:
208
+ name = finding.get("system")
209
+ if name:
210
+ grouped.setdefault(name, []).append(finding)
211
+ else:
212
+ standalone.append(finding)
213
+ rolled = [_system_finding(name, members) for name, members in grouped.items()]
214
+ return _sorted(standalone + rolled)
215
+
216
+
217
+ def _system_finding(name: str, members: list[dict]) -> dict:
218
+ lead = max(members, key=lambda item: severity_grades.rank(item["severity"]))
219
+ citation_ids: list[str] = []
220
+ for member in members:
221
+ for citation_id in member["citation_ids"]:
222
+ if citation_id not in citation_ids:
223
+ citation_ids.append(citation_id)
224
+ frameworks = sorted({member["framework"] for member in members})
225
+ changes = sorted({member["change"] for member in members})
226
+ authors: list[dict] = []
227
+ for member in members:
228
+ author = member.get("author")
229
+ if author and author not in authors:
230
+ authors.append(author)
231
+ missing = any(member["model_card_status"] == "missing" for member in members)
232
+ if len(members) == 1:
233
+ summary = f"System '{name}': {lead['summary']}"
234
+ else:
235
+ summary = (
236
+ f"System '{name}': {len(members)} components changed "
237
+ f"({', '.join(changes)}). {lead['summary']}"
238
+ )
239
+ return {
240
+ "kind": "system",
241
+ "component_id": f"system:{name}",
242
+ "system": name,
243
+ "path": lead["path"],
244
+ "line": lead["line"],
245
+ "framework": ", ".join(frameworks),
246
+ "frameworks": frameworks,
247
+ "change": lead["change"],
248
+ "changes": changes,
249
+ "impact": "runtime"
250
+ if any(member["impact"] == "runtime" for member in members)
251
+ else "documentation",
252
+ "severity": lead["severity"],
253
+ "status": "missing" if missing else "needs_review",
254
+ "summary": summary,
255
+ "model_card_status": "missing" if missing else "present",
256
+ "citation_ids": citation_ids,
257
+ "owner": lead.get("owner"),
258
+ "author": lead.get("author"),
259
+ "authors": authors,
260
+ "members": [_member(member) for member in members],
261
+ "waiver": None,
262
+ "waiver_state": "none",
263
+ }
264
+
265
+
266
+ def _member(finding: dict) -> dict:
267
+ return {
268
+ "component_id": finding["component_id"],
269
+ "path": finding["path"],
270
+ "line": finding["line"],
271
+ "framework": finding["framework"],
272
+ "change": finding["change"],
273
+ "impact": finding["impact"],
274
+ "severity": finding["severity"],
275
+ "author": finding.get("author"),
276
+ }
277
+
278
+
279
+ def _added(component: dict) -> dict:
280
+ missing = component["model_card_status"] == "missing"
281
+ summary = (
282
+ "New component, and no model card was found."
283
+ if missing
284
+ else "New component. A model card is present. Confirm that the card describes this component."
285
+ )
286
+ return _finding(
287
+ component,
288
+ change="added",
289
+ status="missing" if missing else "needs_review",
290
+ summary=summary,
291
+ )
292
+
293
+
294
+ def _removed(component: dict) -> dict:
295
+ return _finding(
296
+ component,
297
+ change="removed",
298
+ status="needs_review",
299
+ summary=(
300
+ "This component was in the base state and is absent from the head state. "
301
+ "Check whether a model card still describes it."
302
+ ),
303
+ )
304
+
305
+
306
+ def _changed(before: dict, after: dict) -> list[dict]:
307
+ evidence_changed = _evidence_key(before) != _evidence_key(after)
308
+ card_changed = before.get("model_card_sha256") != after.get("model_card_sha256")
309
+ missing = after["model_card_status"] == "missing"
310
+ if not evidence_changed and not card_changed:
311
+ return []
312
+
313
+ if missing and before["model_card_status"] == "present":
314
+ change = "card_removed"
315
+ summary = "The model card that was present in the base state is gone."
316
+ status = "missing"
317
+ elif before["model_card_status"] == "missing" and not missing:
318
+ change = "card_added"
319
+ summary = (
320
+ "A model card appeared for this component. "
321
+ "Confirm that it describes this component and not only its neighbours."
322
+ )
323
+ status = "needs_review"
324
+ elif evidence_changed and missing:
325
+ change = "evidence_changed"
326
+ summary = "The detected evidence changed, and there is still no model card."
327
+ status = "missing"
328
+ elif evidence_changed and not card_changed:
329
+ change = "card_stale"
330
+ summary = (
331
+ "The detected evidence changed and the model card content did not. "
332
+ "Article 11 asks that technical documentation of a high-risk system be kept up to date. "
333
+ "Confirm whether that article applies, and whether this card still matches the code."
334
+ )
335
+ status = "needs_review"
336
+ elif evidence_changed and card_changed:
337
+ change = "evidence_and_card_changed"
338
+ summary = "The detected evidence and the model card both changed. Read them together."
339
+ status = "needs_review"
340
+ else:
341
+ change = "card_changed"
342
+ summary = "The model card changed and the detected evidence did not."
343
+ status = "needs_review"
344
+ return [_finding(after, change=change, status=status, summary=summary)]
345
+
346
+
347
+ def _finding(component: dict, change: str, status: str, summary: str) -> dict:
348
+ citation_ids: list[str] = []
349
+ for hint in component["review_hints"]:
350
+ for citation_id in hint["citation_ids"]:
351
+ if citation_id not in citation_ids:
352
+ citation_ids.append(citation_id)
353
+ for citation_id in component["documentation_citation_ids"]:
354
+ if citation_id not in citation_ids:
355
+ citation_ids.append(citation_id)
356
+ documented = component["model_card_status"] == "present"
357
+ impact, level = severity_grades.grade(change, documented)
358
+ evidence = component["evidence"]
359
+ return {
360
+ "kind": "component",
361
+ "component_id": component["id"],
362
+ "path": component["path"],
363
+ "line": evidence[0]["line"] if evidence else 1,
364
+ "framework": component["framework"],
365
+ "system": component.get("system"),
366
+ "change": change,
367
+ "impact": impact,
368
+ "severity": level,
369
+ "status": status,
370
+ "summary": summary,
371
+ "model_card_status": component["model_card_status"],
372
+ "citation_ids": citation_ids,
373
+ "owner": None,
374
+ "author": None,
375
+ "waiver": None,
376
+ "waiver_state": "none",
377
+ }
378
+
379
+
380
+ def _evidence_key(component: dict) -> tuple[tuple[str, str], ...]:
381
+ return tuple((item["kind"], item["text"]) for item in component["evidence"])
382
+
383
+
384
+ def _scan_ref(repo: Path, rev: str) -> dict:
385
+ raw = _git_bytes(repo, "archive", rev)
386
+ temp = Path(tempfile.mkdtemp(prefix="flyleaf-"))
387
+ try:
388
+ with tarfile.open(fileobj=io.BytesIO(raw), mode="r:") as archive:
389
+ kwargs = {"filter": "data"} if sys.version_info >= (3, 12) else {}
390
+ archive.extractall(temp, **kwargs)
391
+ return scan_path(temp)
392
+ finally:
393
+ shutil.rmtree(temp, ignore_errors=True)
394
+
395
+
396
+ def _git_root(start: Path) -> Path:
397
+ return Path(_git(start, "rev-parse", "--show-toplevel"))
398
+
399
+
400
+ def _rev_parse(repo: Path, ref: str) -> str:
401
+ return _git(repo, "rev-parse", "--verify", ref)
402
+
403
+
404
+ def _git(repo: Path, *args: str) -> str:
405
+ return _run(repo, args, text=True).strip()
406
+
407
+
408
+ def _git_bytes(repo: Path, *args: str) -> bytes:
409
+ return _run(repo, args, text=False)
410
+
411
+
412
+ def _run(repo: Path, args: tuple[str, ...], text: bool):
413
+ try:
414
+ completed = subprocess.run(
415
+ ["git", "-C", str(repo), *args],
416
+ check=True,
417
+ capture_output=True,
418
+ text=text,
419
+ )
420
+ except FileNotFoundError as exc:
421
+ raise GitError("git is not installed.") from exc
422
+ except subprocess.CalledProcessError as exc:
423
+ message = exc.stderr or exc.stdout or "git failed"
424
+ if isinstance(message, bytes):
425
+ message = message.decode(errors="replace")
426
+ raise GitError(str(message).strip()) from exc
427
+ return completed.stdout