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/report.py ADDED
@@ -0,0 +1,393 @@
1
+ # Copyright 2026 Krishna Dahale
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Render inventories, briefs, and citation entries."""
4
+
5
+ import json
6
+
7
+ from flyleaf import DISCLAIMER, __version__, attribution
8
+ from flyleaf.citations import CHANGELOG, CITATIONS, PACK_VERSION, citation_payload
9
+ from flyleaf.severity import SEVERITY_NOTE
10
+
11
+ FORMATS = ("json", "markdown", "sarif")
12
+ HOMEPAGE = "https://github.com/krishyaid-coder/flyleaf"
13
+ _SARIF_LEVEL = {"high": "error", "medium": "warning", "low": "note"}
14
+
15
+
16
+ def render(document: dict, output_format: str) -> str:
17
+ if output_format == "sarif":
18
+ if document.get("kind") != "brief":
19
+ raise ValueError("sarif is available for brief only")
20
+ return json.dumps(_sarif(document), indent=2) + "\n"
21
+ if output_format == "json":
22
+ return json.dumps(document, indent=2) + "\n"
23
+ if output_format != "markdown":
24
+ raise ValueError(f"unknown format: {output_format}")
25
+ if document.get("kind") == "brief":
26
+ return _brief_markdown(document)
27
+ return _inventory_markdown(document)
28
+
29
+
30
+ def render_citations(citation_id: str | None, changelog: bool, output_format: str) -> str:
31
+ if changelog:
32
+ document = {"citation_pack": {"version": PACK_VERSION}, "changelog": list(CHANGELOG)}
33
+ elif citation_id is None:
34
+ document = {
35
+ "citation_pack": {"version": PACK_VERSION},
36
+ "citations": {item_id: citation_payload(item_id) for item_id in sorted(CITATIONS)},
37
+ }
38
+ else:
39
+ if citation_id not in CITATIONS:
40
+ raise KeyError(citation_id)
41
+ document = {
42
+ "citation_pack": {"version": PACK_VERSION},
43
+ "citations": {citation_id: citation_payload(citation_id)},
44
+ }
45
+ if output_format == "json":
46
+ return json.dumps(document, indent=2) + "\n"
47
+ if output_format != "markdown":
48
+ raise ValueError(f"unknown format: {output_format}")
49
+ return _citations_markdown(document, changelog)
50
+
51
+
52
+ def _inventory_markdown(inventory: dict) -> str:
53
+ components = inventory["components"]
54
+ pack = inventory["citation_pack"]["version"]
55
+ lines = [
56
+ "# flyleaf inventory",
57
+ "",
58
+ DISCLAIMER,
59
+ "",
60
+ f"Citation pack: {pack}",
61
+ "",
62
+ f"Components: {len(components)}",
63
+ "",
64
+ ]
65
+ if not components:
66
+ lines.append("No AI libraries detected.")
67
+ lines.append("")
68
+ lines.extend(_systems_lines(inventory))
69
+ for component in components:
70
+ lines.extend(_component_lines(component, inventory["citations"], pack))
71
+ warnings = inventory["warnings"]
72
+ if warnings:
73
+ lines.append("## Warnings")
74
+ lines.append("")
75
+ for warning in warnings:
76
+ lines.append(f"- `{warning['path']}`: {warning['message']}")
77
+ lines.append("")
78
+ return "\n".join(lines)
79
+
80
+
81
+ def _systems_lines(inventory: dict) -> list[str]:
82
+ systems = inventory.get("systems") or []
83
+ components = inventory["components"]
84
+ if not systems:
85
+ return []
86
+ unassigned = [item["id"] for item in components if not item["system"]]
87
+ lines = ["## Systems", "", "Declared in `.flyleaf/systems.toml`. flyleaf does not infer them.", ""]
88
+ for system in systems:
89
+ card = system["model_card_path"] or system["model_card_status"]
90
+ lines.append(f"### {system['name']}")
91
+ lines.append("")
92
+ lines.append(f"- Owner: {system['owner'] or 'not stated'}")
93
+ lines.append(f"- Model card: {card}")
94
+ lines.append(f"- Frameworks: {', '.join(system['frameworks']) or 'none detected'}")
95
+ lines.append(f"- Components: {len(system['component_ids'])}")
96
+ for component_id in system["component_ids"]:
97
+ lines.append(f" - `{component_id}`")
98
+ lines.append("")
99
+ lines.append(f"Components in no declared system: {len(unassigned)}")
100
+ lines.append("")
101
+ for component_id in unassigned:
102
+ lines.append(f"- `{component_id}`")
103
+ if unassigned:
104
+ lines.append("")
105
+ return lines
106
+
107
+
108
+ def _component_lines(component: dict, citations: dict, pack: str) -> list[str]:
109
+ card = component["model_card_status"]
110
+ if component["model_card_path"]:
111
+ card = f"{card} (`{component['model_card_path']}`)"
112
+ lines = [
113
+ f"## {component['path']} ({component['framework']})",
114
+ "",
115
+ f"- Category: {component['category']}",
116
+ f"- Role hint: {component['role_hint']}",
117
+ f"- System: {component['system'] or 'none declared'}",
118
+ f"- Model card: {card}",
119
+ "- Evidence:",
120
+ ]
121
+ for item in component["evidence"]:
122
+ lines.append(f" - line {item['line']}: `{item['text']}` ({item['kind']})")
123
+ lines.append("- Areas to review:")
124
+ for hint in component["review_hints"]:
125
+ lines.append(f" - {hint['text']}")
126
+ lines.extend(_citation_lines(hint["citation_ids"], citations, pack))
127
+ lines.append("- Documentation:")
128
+ lines.append(f" - status {component['model_card_status']}")
129
+ lines.extend(_citation_lines(component["documentation_citation_ids"], citations, pack))
130
+ lines.append("")
131
+ return lines
132
+
133
+
134
+ def _brief_markdown(brief: dict) -> str:
135
+ findings = brief["findings"]
136
+ waived = brief["waived"]
137
+ pack = brief["citation_pack"]["version"]
138
+ head = brief["head"]
139
+ head_line = head["ref"]
140
+ if head.get("dirty"):
141
+ head_line = f"{head_line} (uncommitted changes)"
142
+ base = brief["base"]
143
+ base_line = base["ref"] or "HEAD"
144
+ if base.get("recorded_at"):
145
+ base_line = f"{base_line} (recorded {base['recorded_at']})"
146
+ elif base.get("rev"):
147
+ base_line = f"{base_line} ({base['rev']})"
148
+
149
+ lines = [
150
+ "# flyleaf brief",
151
+ "",
152
+ DISCLAIMER,
153
+ "",
154
+ SEVERITY_NOTE,
155
+ "",
156
+ f"Citation pack: {pack}",
157
+ "",
158
+ f"Base: {base_line}",
159
+ f"Head: {head_line}",
160
+ "",
161
+ f"Findings: {len(findings)} ({_severity_tally(findings)})",
162
+ f"Waived: {len(waived)}",
163
+ "",
164
+ ]
165
+ if not findings:
166
+ lines.append("No compliance-relevant change.")
167
+ lines.append("")
168
+ lines.extend(_reviewer_lines(findings))
169
+ for finding in findings:
170
+ lines.extend(_finding_lines(finding, brief["citations"], pack))
171
+ if waived:
172
+ lines.append("## Waived")
173
+ lines.append("")
174
+ for finding in waived:
175
+ waiver = finding["waiver"]
176
+ lines.append(
177
+ f"- {_subject(finding)}, {finding['change']}, severity {finding['severity']}"
178
+ )
179
+ lines.append(
180
+ f" waived by {waiver['approved_by']} until {waiver['expires']} "
181
+ f"({waiver['days_left']} day(s) left): {waiver['reason']}"
182
+ )
183
+ lines.append("")
184
+ if brief["warnings"]:
185
+ lines.append("## Warnings")
186
+ lines.append("")
187
+ for warning in brief["warnings"]:
188
+ lines.append(f"- {warning}")
189
+ lines.append("")
190
+ return "\n".join(lines)
191
+
192
+
193
+ def reviewer_of(finding: dict) -> str:
194
+ """Who should look at this finding.
195
+
196
+ The person who made the change is asked first, because they have the
197
+ context. The declared owner stands behind it either way.
198
+ """
199
+ author = finding.get("author")
200
+ if author and not author["uncommitted"] and not author["is_bot"]:
201
+ return author["email"] or author["name"] or "unassigned"
202
+ owner = finding.get("owner")
203
+ if owner:
204
+ return owner
205
+ if author:
206
+ return attribution.label(author)
207
+ return "unassigned"
208
+
209
+
210
+ def _reviewer_lines(findings: list[dict]) -> list[str]:
211
+ if not findings:
212
+ return []
213
+ buckets: dict[str, list[dict]] = {}
214
+ for finding in findings:
215
+ buckets.setdefault(reviewer_of(finding), []).append(finding)
216
+ lines = ["## Who should look at this", ""]
217
+ for reviewer, owned in sorted(buckets.items(), key=lambda item: (-len(item[1]), item[0])):
218
+ subjects = ", ".join(dict.fromkeys(_subject(item) for item in owned))
219
+ lines.append(f"- {reviewer}: {len(owned)} ({_severity_tally(owned)}). {subjects}.")
220
+ lines.append("")
221
+ lines.append(
222
+ "Names come from the declared system owner and from git blame on the line "
223
+ "that changed. Blame names a line, not a fault."
224
+ )
225
+ lines.append("")
226
+ return lines
227
+
228
+
229
+ def _subject(finding: dict) -> str:
230
+ if finding.get("kind") == "system":
231
+ return f"system {finding['system']} ({finding['framework']})"
232
+ return f"{finding['path']} ({finding['framework']})"
233
+
234
+
235
+ def _finding_lines(finding: dict, citations: dict, pack: str) -> list[str]:
236
+ lines = [
237
+ f"## [{finding['severity']}] {_subject(finding)}",
238
+ "",
239
+ f"- Change: {finding['change']}",
240
+ f"- Impact: {finding['impact']}",
241
+ f"- Status: {finding['status']}",
242
+ f"- Model card: {finding['model_card_status']}",
243
+ f"- Owner: {finding.get('owner') or 'none declared'}",
244
+ f"- Last changed by: {attribution.label(finding.get('author'))}",
245
+ f"- {finding['summary']}",
246
+ ]
247
+ if finding.get("waiver_state") == "expired":
248
+ lines.append("- Waiver: expired")
249
+ members = finding.get("members")
250
+ if members:
251
+ lines.append(f"- Components ({len(members)}):")
252
+ for member in members:
253
+ lines.append(
254
+ f" - `{member['path']}` line {member['line']} ({member['framework']}), "
255
+ f"{member['change']}, severity {member['severity']}, "
256
+ f"last changed by {attribution.label(member.get('author'))}"
257
+ )
258
+ lines.append("- Read:")
259
+ lines.extend(_citation_lines(finding["citation_ids"], citations, pack))
260
+ lines.append("")
261
+ return lines
262
+
263
+
264
+ def _severity_tally(findings: list[dict]) -> str:
265
+ counts = {"high": 0, "medium": 0, "low": 0}
266
+ for finding in findings:
267
+ counts[finding["severity"]] = counts.get(finding["severity"], 0) + 1
268
+ return ", ".join(f"{level} {counts[level]}" for level in ("high", "medium", "low"))
269
+
270
+
271
+ def _sarif(brief: dict) -> dict:
272
+ rules: dict[str, dict] = {}
273
+ results: list[dict] = []
274
+ for finding in brief["findings"]:
275
+ rule_id = f"flyleaf/{finding['change']}"
276
+ rules.setdefault(
277
+ rule_id,
278
+ {
279
+ "id": rule_id,
280
+ "name": finding["change"],
281
+ "shortDescription": {"text": _rule_text(finding["change"])},
282
+ "fullDescription": {"text": SEVERITY_NOTE},
283
+ "helpUri": HOMEPAGE,
284
+ "properties": {"impact": finding["impact"]},
285
+ },
286
+ )
287
+ citations = ", ".join(
288
+ brief["citations"][citation_id]["pinpoint"] for citation_id in finding["citation_ids"]
289
+ )
290
+ results.append(
291
+ {
292
+ "ruleId": rule_id,
293
+ "level": _SARIF_LEVEL.get(finding["severity"], "warning"),
294
+ "message": {"text": f"{finding['summary']} Read: {citations}."},
295
+ "locations": _sarif_locations(finding),
296
+ "properties": {
297
+ "severity": finding["severity"],
298
+ "impact": finding["impact"],
299
+ "status": finding["status"],
300
+ "component": finding["component_id"],
301
+ "system": finding.get("system"),
302
+ "owner": finding.get("owner"),
303
+ "author": attribution.label(finding.get("author")),
304
+ },
305
+ }
306
+ )
307
+ return {
308
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
309
+ "version": "2.1.0",
310
+ "runs": [
311
+ {
312
+ "tool": {
313
+ "driver": {
314
+ "name": "flyleaf",
315
+ "version": __version__,
316
+ "informationUri": HOMEPAGE,
317
+ "rules": list(rules.values()),
318
+ }
319
+ },
320
+ "results": results,
321
+ }
322
+ ],
323
+ }
324
+
325
+
326
+ def _sarif_locations(finding: dict) -> list[dict]:
327
+ """The lead location comes first, because GitHub annotates the first one."""
328
+ places = [(finding["path"], finding["line"])]
329
+ for member in finding.get("members") or []:
330
+ place = (member["path"], member["line"])
331
+ if place not in places:
332
+ places.append(place)
333
+ return [
334
+ {
335
+ "physicalLocation": {
336
+ "artifactLocation": {"uri": path},
337
+ "region": {"startLine": max(1, line)},
338
+ }
339
+ }
340
+ for path, line in places
341
+ ]
342
+
343
+
344
+ def _rule_text(change: str) -> str:
345
+ return {
346
+ "added": "A new AI component appeared.",
347
+ "removed": "A detected AI component is gone.",
348
+ "evidence_changed": "The detected AI code changed.",
349
+ "card_stale": "The AI code changed and the model card did not.",
350
+ "card_removed": "A model card was removed.",
351
+ "card_added": "A model card appeared.",
352
+ "card_changed": "A model card changed.",
353
+ "evidence_and_card_changed": "The AI code and the model card both changed.",
354
+ }.get(change, change)
355
+
356
+
357
+ def _citations_markdown(document: dict, changelog: bool) -> str:
358
+ pack = document["citation_pack"]["version"]
359
+ lines = ["# flyleaf citation pack", "", f"Version: {pack}", ""]
360
+ if changelog:
361
+ for entry in document["changelog"]:
362
+ lines.append(f"## {entry['version']}")
363
+ lines.append("")
364
+ lines.append(entry["summary"])
365
+ lines.append("")
366
+ return "\n".join(lines)
367
+ for citation in document["citations"].values():
368
+ lines.extend(
369
+ [
370
+ f"## {citation['id']}",
371
+ "",
372
+ f"{citation['pinpoint']}",
373
+ "",
374
+ f"{citation['instrument']} ({citation['celex']}), status {citation['status']}.",
375
+ "",
376
+ f"> {citation['quote']}",
377
+ "",
378
+ citation["note"],
379
+ "",
380
+ citation["source_url"],
381
+ "",
382
+ ]
383
+ )
384
+ return "\n".join(lines)
385
+
386
+
387
+ def _citation_lines(citation_ids: list[str], citations: dict, pack: str) -> list[str]:
388
+ lines: list[str] = []
389
+ for citation_id in citation_ids:
390
+ citation = citations[citation_id]
391
+ lines.append(f" - {citation['pinpoint']} ({citation['celex']}, pack {pack})")
392
+ lines.append(f" {citation['source_url']}")
393
+ return lines
flyleaf/rules.py ADDED
@@ -0,0 +1,258 @@
1
+ # Copyright 2026 Krishna Dahale
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Detection rules.
4
+
5
+ A rule matches a library, not a legal outcome. Role hints and review hints
6
+ are prompts for a person. They are not a risk tier.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ReviewHint:
14
+ id: str
15
+ text: str
16
+ citation_ids: tuple[str, ...]
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class Rule:
21
+ framework: str
22
+ category: str
23
+ role_hint: str
24
+ packages: frozenset[str]
25
+ import_roots: frozenset[str]
26
+ review_hints: tuple[ReviewHint, ...]
27
+
28
+
29
+ _API_HINTS = (
30
+ ReviewHint(
31
+ id="role",
32
+ text=(
33
+ "This looks like a client of a hosted model. "
34
+ "Confirm whether you provide the model or deploy someone else's. "
35
+ "Those roles carry different duties."
36
+ ),
37
+ citation_ids=("art-3-provider", "art-3-deployer", "art-53-gpai"),
38
+ ),
39
+ ReviewHint(
40
+ id="transparency",
41
+ text=(
42
+ "If people receive generated text, audio, or images, "
43
+ "Article 50 transparency is an area to review."
44
+ ),
45
+ citation_ids=("art-50-interaction",),
46
+ ),
47
+ )
48
+
49
+ _ORCHESTRATION_HINTS = (
50
+ ReviewHint(
51
+ id="role",
52
+ text=(
53
+ "Orchestration code composes model calls. "
54
+ "Review the system those calls sit inside. "
55
+ "The import does not identify the use case."
56
+ ),
57
+ citation_ids=("art-3-provider", "art-3-deployer"),
58
+ ),
59
+ )
60
+
61
+ _ML_HINTS = (
62
+ ReviewHint(
63
+ id="use_case",
64
+ text=(
65
+ "A machine-learning library does not say whether a use case is high-risk. "
66
+ "Annex III depends on purpose. Fill that in yourself."
67
+ ),
68
+ citation_ids=("art-6-annex-iii",),
69
+ ),
70
+ )
71
+
72
+
73
+ def _rule(
74
+ framework: str,
75
+ category: str,
76
+ role_hint: str,
77
+ packages: tuple[str, ...],
78
+ import_roots: tuple[str, ...],
79
+ review_hints: tuple[ReviewHint, ...],
80
+ ) -> Rule:
81
+ return Rule(
82
+ framework=framework,
83
+ category=category,
84
+ role_hint=role_hint,
85
+ packages=frozenset(packages),
86
+ import_roots=frozenset(import_roots),
87
+ review_hints=review_hints,
88
+ )
89
+
90
+
91
+ RULES: tuple[Rule, ...] = (
92
+ _rule(
93
+ "openai",
94
+ "llm_api",
95
+ "api_client",
96
+ ("openai",),
97
+ ("openai",),
98
+ _API_HINTS,
99
+ ),
100
+ _rule(
101
+ "anthropic",
102
+ "llm_api",
103
+ "api_client",
104
+ ("anthropic", "@anthropic-ai/sdk"),
105
+ ("anthropic",),
106
+ _API_HINTS,
107
+ ),
108
+ _rule(
109
+ "google-genai",
110
+ "llm_api",
111
+ "api_client",
112
+ (
113
+ "google-generativeai",
114
+ "google-genai",
115
+ "@google/generative-ai",
116
+ "@google/genai",
117
+ ),
118
+ ("google.generativeai", "google.genai"),
119
+ _API_HINTS,
120
+ ),
121
+ _rule(
122
+ "mistralai",
123
+ "llm_api",
124
+ "api_client",
125
+ ("mistralai", "@mistralai/mistralai"),
126
+ ("mistralai",),
127
+ _API_HINTS,
128
+ ),
129
+ _rule(
130
+ "cohere",
131
+ "llm_api",
132
+ "api_client",
133
+ ("cohere", "cohere-ai"),
134
+ ("cohere",),
135
+ _API_HINTS,
136
+ ),
137
+ _rule(
138
+ "langchain",
139
+ "orchestration",
140
+ "orchestration",
141
+ (
142
+ "langchain",
143
+ "langchain-core",
144
+ "langchain-openai",
145
+ "langchain-community",
146
+ "langchain-anthropic",
147
+ "langchain-google-genai",
148
+ "@langchain/core",
149
+ "@langchain/openai",
150
+ "@langchain/community",
151
+ "@langchain/anthropic",
152
+ "@langchain/google-genai",
153
+ ),
154
+ (
155
+ "langchain",
156
+ "langchain_core",
157
+ "langchain_openai",
158
+ "langchain_community",
159
+ "langchain_anthropic",
160
+ "langchain_google_genai",
161
+ ),
162
+ _ORCHESTRATION_HINTS,
163
+ ),
164
+ _rule(
165
+ "llama-index",
166
+ "orchestration",
167
+ "orchestration",
168
+ ("llama-index", "llama-index-core", "llamaindex"),
169
+ ("llama_index",),
170
+ _ORCHESTRATION_HINTS,
171
+ ),
172
+ _rule(
173
+ "langgraph",
174
+ "orchestration",
175
+ "orchestration",
176
+ ("langgraph", "@langchain/langgraph"),
177
+ ("langgraph",),
178
+ _ORCHESTRATION_HINTS,
179
+ ),
180
+ _rule(
181
+ "torch",
182
+ "ml_framework",
183
+ "local_ml",
184
+ ("torch", "torchvision", "torchaudio"),
185
+ ("torch", "torchvision", "torchaudio"),
186
+ _ML_HINTS,
187
+ ),
188
+ _rule(
189
+ "tensorflow",
190
+ "ml_framework",
191
+ "local_ml",
192
+ ("tensorflow", "tensorflow-cpu", "tensorflow-macos"),
193
+ ("tensorflow",),
194
+ _ML_HINTS,
195
+ ),
196
+ _rule(
197
+ "scikit-learn",
198
+ "ml_framework",
199
+ "local_ml",
200
+ ("scikit-learn", "sklearn"),
201
+ ("sklearn",),
202
+ _ML_HINTS,
203
+ ),
204
+ _rule(
205
+ "transformers",
206
+ "ml_framework",
207
+ "local_ml",
208
+ ("transformers",),
209
+ ("transformers",),
210
+ _ML_HINTS,
211
+ ),
212
+ _rule(
213
+ "xgboost",
214
+ "ml_framework",
215
+ "local_ml",
216
+ ("xgboost",),
217
+ ("xgboost",),
218
+ _ML_HINTS,
219
+ ),
220
+ )
221
+
222
+
223
+ def match_import(module: str) -> Rule | None:
224
+ """Return the rule with the longest import root that covers `module`."""
225
+ best: Rule | None = None
226
+ best_len = -1
227
+ for rule in RULES:
228
+ for root in rule.import_roots:
229
+ covers = module == root or module.startswith(root + ".")
230
+ if covers and len(root) > best_len:
231
+ best = rule
232
+ best_len = len(root)
233
+ return best
234
+
235
+
236
+ _PACKAGES: dict[str, Rule] = {}
237
+ for _rule_item in RULES:
238
+ for _package in _rule_item.packages:
239
+ if _package in _PACKAGES:
240
+ raise RuntimeError(f"package {_package} is claimed by two rules")
241
+ _PACKAGES[_package] = _rule_item
242
+
243
+
244
+ def match_package(name: str) -> Rule | None:
245
+ return _PACKAGES.get(name)
246
+
247
+
248
+ def _validate_citation_ids() -> None:
249
+ from flyleaf.citations import CITATIONS
250
+
251
+ for rule in RULES:
252
+ for hint in rule.review_hints:
253
+ missing = [citation_id for citation_id in hint.citation_ids if citation_id not in CITATIONS]
254
+ if missing:
255
+ raise RuntimeError(f"{rule.framework} hint {hint.id} cites unknown ids: {missing}")
256
+
257
+
258
+ _validate_citation_ids()