pybinaryguard 1.0.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.
- pybinaryguard/__init__.py +78 -0
- pybinaryguard/__main__.py +7 -0
- pybinaryguard/_compat/__init__.py +0 -0
- pybinaryguard/agent/__init__.py +63 -0
- pybinaryguard/agent/guard.py +232 -0
- pybinaryguard/agent/recommender.py +283 -0
- pybinaryguard/agent/schema.py +200 -0
- pybinaryguard/agent/simulator.py +430 -0
- pybinaryguard/agent/tool_interface.py +474 -0
- pybinaryguard/analyzers/__init__.py +209 -0
- pybinaryguard/analyzers/base.py +40 -0
- pybinaryguard/analyzers/dependency_analyzer.py +336 -0
- pybinaryguard/analyzers/elf_analyzer.py +754 -0
- pybinaryguard/analyzers/symbol_analyzer.py +280 -0
- pybinaryguard/analyzers/wheel_analyzer.py +308 -0
- pybinaryguard/cli/__init__.py +5 -0
- pybinaryguard/cli/commands.py +414 -0
- pybinaryguard/cli/formatters.py +720 -0
- pybinaryguard/cli/main.py +250 -0
- pybinaryguard/diagnostics/__init__.py +13 -0
- pybinaryguard/diagnostics/explainer.py +356 -0
- pybinaryguard/diagnostics/findings.py +146 -0
- pybinaryguard/diagnostics/suggestions.py +508 -0
- pybinaryguard/frameworks/__init__.py +20 -0
- pybinaryguard/frameworks/onnxruntime.py +214 -0
- pybinaryguard/frameworks/pytorch.py +223 -0
- pybinaryguard/frameworks/tensorflow.py +266 -0
- pybinaryguard/frameworks/tensorrt.py +189 -0
- pybinaryguard/models/__init__.py +19 -0
- pybinaryguard/models/enums.py +102 -0
- pybinaryguard/models/finding.py +109 -0
- pybinaryguard/models/package.py +121 -0
- pybinaryguard/models/system.py +118 -0
- pybinaryguard/plugins/__init__.py +19 -0
- pybinaryguard/plugins/contrib/__init__.py +7 -0
- pybinaryguard/plugins/contrib/gstreamer.py +109 -0
- pybinaryguard/plugins/contrib/jetson.py +385 -0
- pybinaryguard/plugins/contrib/opencv.py +306 -0
- pybinaryguard/plugins/contrib/tensorrt.py +426 -0
- pybinaryguard/plugins/hooks.py +273 -0
- pybinaryguard/plugins/loader.py +190 -0
- pybinaryguard/predictor/__init__.py +23 -0
- pybinaryguard/predictor/dependency_graph.py +226 -0
- pybinaryguard/predictor/linker_simulator.py +161 -0
- pybinaryguard/predictor/predictor.py +144 -0
- pybinaryguard/predictor/resolver.py +282 -0
- pybinaryguard/probes/__init__.py +73 -0
- pybinaryguard/probes/base.py +45 -0
- pybinaryguard/probes/board_probe.py +248 -0
- pybinaryguard/probes/cpu_probe.py +176 -0
- pybinaryguard/probes/glibc_probe.py +215 -0
- pybinaryguard/probes/gpu_probe.py +430 -0
- pybinaryguard/probes/library_probe.py +132 -0
- pybinaryguard/probes/os_probe.py +227 -0
- pybinaryguard/probes/python_probe.py +135 -0
- pybinaryguard/probes/toolchain_probe.py +89 -0
- pybinaryguard/probes/venv_probe.py +111 -0
- pybinaryguard/profiles/__init__.py +12 -0
- pybinaryguard/profiles/engine.py +343 -0
- pybinaryguard/rules/__init__.py +24 -0
- pybinaryguard/rules/base.py +57 -0
- pybinaryguard/rules/builtin/__init__.py +136 -0
- pybinaryguard/rules/builtin/arch_rules.py +86 -0
- pybinaryguard/rules/builtin/board_profile_rules.py +282 -0
- pybinaryguard/rules/builtin/container_rules.py +170 -0
- pybinaryguard/rules/builtin/cpu_rules.py +204 -0
- pybinaryguard/rules/builtin/cuda_rules.py +760 -0
- pybinaryguard/rules/builtin/dependency_rules.py +278 -0
- pybinaryguard/rules/builtin/framework_rules.py +252 -0
- pybinaryguard/rules/builtin/glibc_rules.py +320 -0
- pybinaryguard/rules/builtin/numpy_rules.py +110 -0
- pybinaryguard/rules/builtin/predictive_rules.py +165 -0
- pybinaryguard/rules/builtin/python_abi_rules.py +259 -0
- pybinaryguard/rules/builtin/source_build_rules.py +150 -0
- pybinaryguard/rules/builtin/venv_rules.py +159 -0
- pybinaryguard/rules/engine.py +123 -0
- pybinaryguard/scanner.py +904 -0
- pybinaryguard/scoring/__init__.py +19 -0
- pybinaryguard/scoring/engine.py +416 -0
- pybinaryguard/snapshot/__init__.py +20 -0
- pybinaryguard/snapshot/generator.py +168 -0
- pybinaryguard/snapshot/lockfile.py +152 -0
- pybinaryguard/snapshot/verifier.py +315 -0
- pybinaryguard/validators/__init__.py +7 -0
- pybinaryguard/validators/import_validator.py +231 -0
- pybinaryguard-1.0.0.dist-info/METADATA +876 -0
- pybinaryguard-1.0.0.dist-info/RECORD +91 -0
- pybinaryguard-1.0.0.dist-info/WHEEL +5 -0
- pybinaryguard-1.0.0.dist-info/entry_points.txt +8 -0
- pybinaryguard-1.0.0.dist-info/licenses/LICENSE +21 -0
- pybinaryguard-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
"""Output formatters for the PyBinaryGuard CLI.
|
|
2
|
+
|
|
3
|
+
This module provides three formatters:
|
|
4
|
+
|
|
5
|
+
- :class:`TableFormatter` -- human-readable table output with optional ANSI
|
|
6
|
+
colour codes (default).
|
|
7
|
+
- :class:`JSONFormatter` -- machine-readable JSON output.
|
|
8
|
+
- :class:`MinimalFormatter` -- one-line-per-finding output.
|
|
9
|
+
|
|
10
|
+
All formatters implement the :class:`FormatterBase` abstract interface.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
from abc import ABC, abstractmethod
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
from pybinaryguard.models import Finding, ScanReport, Severity, SystemProfile
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# ANSI colour helpers
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
class _Colours:
|
|
28
|
+
"""ANSI escape sequences for terminal colours."""
|
|
29
|
+
|
|
30
|
+
RESET = "\033[0m"
|
|
31
|
+
BOLD = "\033[1m"
|
|
32
|
+
RED = "\033[31m"
|
|
33
|
+
RED_BOLD = "\033[1;31m"
|
|
34
|
+
YELLOW = "\033[33m"
|
|
35
|
+
YELLOW_BOLD = "\033[1;33m"
|
|
36
|
+
GREEN = "\033[32m"
|
|
37
|
+
BLUE = "\033[34m"
|
|
38
|
+
CYAN = "\033[36m"
|
|
39
|
+
DIM = "\033[2m"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _NoColours:
|
|
43
|
+
"""Drop-in replacement that produces no escape sequences."""
|
|
44
|
+
|
|
45
|
+
RESET = ""
|
|
46
|
+
BOLD = ""
|
|
47
|
+
RED = ""
|
|
48
|
+
RED_BOLD = ""
|
|
49
|
+
YELLOW = ""
|
|
50
|
+
YELLOW_BOLD = ""
|
|
51
|
+
GREEN = ""
|
|
52
|
+
BLUE = ""
|
|
53
|
+
CYAN = ""
|
|
54
|
+
DIM = ""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _pick_colours(no_color: bool) -> Any:
|
|
58
|
+
"""Return colour sequences unless --no-color or non-TTY."""
|
|
59
|
+
if no_color or not sys.stdout.isatty():
|
|
60
|
+
return _NoColours
|
|
61
|
+
return _Colours
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Severity formatting helpers
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
def _severity_label(severity: Severity, c: Any) -> str:
|
|
69
|
+
"""Format a severity label with colour."""
|
|
70
|
+
labels = {
|
|
71
|
+
Severity.CRITICAL: f"{c.RED_BOLD}CRITICAL{c.RESET}",
|
|
72
|
+
Severity.WARNING: f"{c.YELLOW_BOLD}WARNING{c.RESET}",
|
|
73
|
+
Severity.INFO: f"{c.BLUE}INFO{c.RESET}",
|
|
74
|
+
Severity.PASSED: f"{c.GREEN}PASSED{c.RESET}",
|
|
75
|
+
}
|
|
76
|
+
return labels.get(severity, severity.value.upper())
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _plain_severity_label(severity: Severity) -> str:
|
|
80
|
+
"""Format a severity label without colour."""
|
|
81
|
+
return severity.value.upper()
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
# Base interface
|
|
86
|
+
# ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
class FormatterBase(ABC):
|
|
89
|
+
"""Abstract base for all output formatters."""
|
|
90
|
+
|
|
91
|
+
@abstractmethod
|
|
92
|
+
def format_scan(
|
|
93
|
+
self,
|
|
94
|
+
report: ScanReport,
|
|
95
|
+
profile: SystemProfile,
|
|
96
|
+
verbose: bool = False,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Format a full scan report.
|
|
99
|
+
|
|
100
|
+
Parameters
|
|
101
|
+
----------
|
|
102
|
+
report:
|
|
103
|
+
The scan report containing findings and metadata.
|
|
104
|
+
profile:
|
|
105
|
+
The system profile collected during the scan.
|
|
106
|
+
verbose:
|
|
107
|
+
When ``True``, include technical detail and suggestions.
|
|
108
|
+
|
|
109
|
+
Returns
|
|
110
|
+
-------
|
|
111
|
+
str
|
|
112
|
+
The formatted output string.
|
|
113
|
+
"""
|
|
114
|
+
...
|
|
115
|
+
|
|
116
|
+
@abstractmethod
|
|
117
|
+
def format_check(
|
|
118
|
+
self,
|
|
119
|
+
findings: List[Finding],
|
|
120
|
+
package: str,
|
|
121
|
+
profile: SystemProfile,
|
|
122
|
+
verbose: bool = False,
|
|
123
|
+
) -> str:
|
|
124
|
+
"""Format findings for a single package check.
|
|
125
|
+
|
|
126
|
+
Parameters
|
|
127
|
+
----------
|
|
128
|
+
findings:
|
|
129
|
+
The list of findings for the package.
|
|
130
|
+
package:
|
|
131
|
+
The package name that was checked.
|
|
132
|
+
profile:
|
|
133
|
+
The system profile.
|
|
134
|
+
verbose:
|
|
135
|
+
When ``True``, include technical detail and suggestions.
|
|
136
|
+
|
|
137
|
+
Returns
|
|
138
|
+
-------
|
|
139
|
+
str
|
|
140
|
+
The formatted output string.
|
|
141
|
+
"""
|
|
142
|
+
...
|
|
143
|
+
|
|
144
|
+
@abstractmethod
|
|
145
|
+
def format_profile(self, profile: SystemProfile) -> str:
|
|
146
|
+
"""Format a system profile for display.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
profile:
|
|
151
|
+
The system profile to display.
|
|
152
|
+
|
|
153
|
+
Returns
|
|
154
|
+
-------
|
|
155
|
+
str
|
|
156
|
+
The formatted output string.
|
|
157
|
+
"""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def format_doctor(self, diagnosis: Dict[str, Any]) -> str:
|
|
162
|
+
"""Format a diagnostic/doctor result.
|
|
163
|
+
|
|
164
|
+
Parameters
|
|
165
|
+
----------
|
|
166
|
+
diagnosis:
|
|
167
|
+
A dictionary with diagnosis information.
|
|
168
|
+
|
|
169
|
+
Returns
|
|
170
|
+
-------
|
|
171
|
+
str
|
|
172
|
+
The formatted output string.
|
|
173
|
+
"""
|
|
174
|
+
...
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# TableFormatter
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
_HEADER_LINE = "\u2501" * 49 # U+2501 BOX DRAWINGS HEAVY HORIZONTAL
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class TableFormatter(FormatterBase):
|
|
185
|
+
"""Human-readable table output with optional ANSI colours.
|
|
186
|
+
|
|
187
|
+
When stdout is a TTY and ``no_color`` is ``False``, findings are
|
|
188
|
+
colour-coded by severity:
|
|
189
|
+
|
|
190
|
+
- CRITICAL = red bold
|
|
191
|
+
- WARNING = yellow bold
|
|
192
|
+
- INFO = blue
|
|
193
|
+
- PASSED = green
|
|
194
|
+
"""
|
|
195
|
+
|
|
196
|
+
def __init__(self, no_color: bool = False) -> None:
|
|
197
|
+
self._c = _pick_colours(no_color)
|
|
198
|
+
|
|
199
|
+
def format_scan(
|
|
200
|
+
self,
|
|
201
|
+
report: ScanReport,
|
|
202
|
+
profile: SystemProfile,
|
|
203
|
+
verbose: bool = False,
|
|
204
|
+
) -> str:
|
|
205
|
+
"""Format a full scan report as a human-readable table."""
|
|
206
|
+
c = self._c
|
|
207
|
+
lines: List[str] = []
|
|
208
|
+
|
|
209
|
+
# Header
|
|
210
|
+
lines.append(
|
|
211
|
+
f"{c.BOLD}PyBinaryGuard v1.0.0 \u2014 Binary Compatibility Scanner{c.RESET}"
|
|
212
|
+
)
|
|
213
|
+
lines.append(f"{c.DIM}{_HEADER_LINE}{c.RESET}")
|
|
214
|
+
lines.append("")
|
|
215
|
+
|
|
216
|
+
# System profile summary
|
|
217
|
+
lines.append(f"{c.BOLD}System Profile{c.RESET}")
|
|
218
|
+
for key, value in profile.summary().items():
|
|
219
|
+
lines.append(f" {key + ':':<16}{value}")
|
|
220
|
+
lines.append("")
|
|
221
|
+
|
|
222
|
+
# Detected board (embedded intelligence)
|
|
223
|
+
if report.detected_board:
|
|
224
|
+
lines.append(
|
|
225
|
+
f"{c.CYAN}\u2022 Detected Board: {c.BOLD}{report.detected_board}{c.RESET}"
|
|
226
|
+
)
|
|
227
|
+
lines.append("")
|
|
228
|
+
|
|
229
|
+
# Scanning summary
|
|
230
|
+
lines.append(
|
|
231
|
+
f"Scanning {report.total_packages} packages... "
|
|
232
|
+
f"{c.GREEN}done{c.RESET}"
|
|
233
|
+
)
|
|
234
|
+
lines.append("")
|
|
235
|
+
|
|
236
|
+
# Results summary
|
|
237
|
+
parts: List[str] = []
|
|
238
|
+
if report.critical_count:
|
|
239
|
+
parts.append(f"{c.RED_BOLD}{report.critical_count} critical{c.RESET}")
|
|
240
|
+
if report.warning_count:
|
|
241
|
+
parts.append(f"{c.YELLOW_BOLD}{report.warning_count} warning{c.RESET}")
|
|
242
|
+
if report.info_count:
|
|
243
|
+
parts.append(f"{c.BLUE}{report.info_count} info{c.RESET}")
|
|
244
|
+
passed = report.total_packages - (
|
|
245
|
+
report.critical_count + report.warning_count + report.info_count
|
|
246
|
+
)
|
|
247
|
+
if passed > 0:
|
|
248
|
+
parts.append(f"{c.GREEN}{passed} passed{c.RESET}")
|
|
249
|
+
|
|
250
|
+
lines.append(f"Results: {', '.join(parts)}")
|
|
251
|
+
lines.append(f"{c.DIM}{_HEADER_LINE}{c.RESET}")
|
|
252
|
+
lines.append("")
|
|
253
|
+
|
|
254
|
+
# Individual findings
|
|
255
|
+
for finding in report.findings:
|
|
256
|
+
lines.extend(self._format_finding(finding, verbose))
|
|
257
|
+
lines.append("")
|
|
258
|
+
|
|
259
|
+
# Health score with v2 breakdown
|
|
260
|
+
score = report.health_score
|
|
261
|
+
label = report.health_label
|
|
262
|
+
if score >= 90:
|
|
263
|
+
score_colour = c.GREEN
|
|
264
|
+
elif score >= 70:
|
|
265
|
+
score_colour = c.YELLOW
|
|
266
|
+
else:
|
|
267
|
+
score_colour = c.RED
|
|
268
|
+
lines.append(
|
|
269
|
+
f"Health Score: {score_colour}{score} / 100{c.RESET} ({label})"
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
# Category breakdown (v2 scoring)
|
|
273
|
+
if report.score_breakdown is not None:
|
|
274
|
+
breakdown = report.score_breakdown
|
|
275
|
+
lines.append("")
|
|
276
|
+
lines.append(f"{c.BOLD}Score Breakdown{c.RESET}")
|
|
277
|
+
for cat in breakdown.categories.values():
|
|
278
|
+
if cat.weight <= 0:
|
|
279
|
+
continue
|
|
280
|
+
cat_score = round(cat.score, 1)
|
|
281
|
+
if cat_score >= 90:
|
|
282
|
+
cat_colour = c.GREEN
|
|
283
|
+
elif cat_score >= 70:
|
|
284
|
+
cat_colour = c.YELLOW
|
|
285
|
+
else:
|
|
286
|
+
cat_colour = c.RED
|
|
287
|
+
weight_pct = round(cat.weight * 100)
|
|
288
|
+
bar = self._score_bar(cat_score)
|
|
289
|
+
lines.append(
|
|
290
|
+
f" {cat.display_name + ':':<24}"
|
|
291
|
+
f"{cat_colour}{bar} {cat_score:>5.1f}{c.RESET}"
|
|
292
|
+
f" {c.DIM}({weight_pct}% weight){c.RESET}"
|
|
293
|
+
)
|
|
294
|
+
if cat.top_issues:
|
|
295
|
+
for issue in cat.top_issues[:2]:
|
|
296
|
+
lines.append(f" {c.DIM}- {issue}{c.RESET}")
|
|
297
|
+
|
|
298
|
+
# Timing
|
|
299
|
+
if report.scan_duration_ms > 0:
|
|
300
|
+
elapsed = report.scan_duration_ms / 1000.0
|
|
301
|
+
lines.append(f"{c.DIM}Scan completed in {elapsed:.1f}s{c.RESET}")
|
|
302
|
+
|
|
303
|
+
return "\n".join(lines)
|
|
304
|
+
|
|
305
|
+
def format_check(
|
|
306
|
+
self,
|
|
307
|
+
findings: List[Finding],
|
|
308
|
+
package: str,
|
|
309
|
+
profile: SystemProfile,
|
|
310
|
+
verbose: bool = False,
|
|
311
|
+
) -> str:
|
|
312
|
+
"""Format findings for a single package check."""
|
|
313
|
+
c = self._c
|
|
314
|
+
lines: List[str] = []
|
|
315
|
+
|
|
316
|
+
lines.append(f"{c.BOLD}PyBinaryGuard \u2014 Package Check: {package}{c.RESET}")
|
|
317
|
+
lines.append(f"{c.DIM}{_HEADER_LINE}{c.RESET}")
|
|
318
|
+
lines.append("")
|
|
319
|
+
|
|
320
|
+
if not findings:
|
|
321
|
+
lines.append(f"{c.GREEN}No issues found for '{package}'.{c.RESET}")
|
|
322
|
+
return "\n".join(lines)
|
|
323
|
+
|
|
324
|
+
for finding in findings:
|
|
325
|
+
lines.extend(self._format_finding(finding, verbose))
|
|
326
|
+
lines.append("")
|
|
327
|
+
|
|
328
|
+
# Summary
|
|
329
|
+
crit = sum(1 for f in findings if f.severity == Severity.CRITICAL)
|
|
330
|
+
warn = sum(1 for f in findings if f.severity == Severity.WARNING)
|
|
331
|
+
info = sum(1 for f in findings if f.severity == Severity.INFO)
|
|
332
|
+
|
|
333
|
+
parts = []
|
|
334
|
+
if crit:
|
|
335
|
+
parts.append(f"{c.RED_BOLD}{crit} critical{c.RESET}")
|
|
336
|
+
if warn:
|
|
337
|
+
parts.append(f"{c.YELLOW_BOLD}{warn} warning{c.RESET}")
|
|
338
|
+
if info:
|
|
339
|
+
parts.append(f"{c.BLUE}{info} info{c.RESET}")
|
|
340
|
+
if not parts:
|
|
341
|
+
parts.append(f"{c.GREEN}all passed{c.RESET}")
|
|
342
|
+
|
|
343
|
+
lines.append(f"Summary: {', '.join(parts)}")
|
|
344
|
+
|
|
345
|
+
return "\n".join(lines)
|
|
346
|
+
|
|
347
|
+
def format_profile(self, profile: SystemProfile) -> str:
|
|
348
|
+
"""Format a system profile for display."""
|
|
349
|
+
c = self._c
|
|
350
|
+
lines: List[str] = []
|
|
351
|
+
|
|
352
|
+
lines.append(
|
|
353
|
+
f"{c.BOLD}PyBinaryGuard v1.0.0 \u2014 System Profile{c.RESET}"
|
|
354
|
+
)
|
|
355
|
+
lines.append(f"{c.DIM}{_HEADER_LINE}{c.RESET}")
|
|
356
|
+
lines.append("")
|
|
357
|
+
|
|
358
|
+
for key, value in profile.summary().items():
|
|
359
|
+
lines.append(f" {key + ':':<16}{value}")
|
|
360
|
+
|
|
361
|
+
# Additional details
|
|
362
|
+
lines.append("")
|
|
363
|
+
lines.append(f"{c.BOLD}Library Paths{c.RESET}")
|
|
364
|
+
if profile.ld_library_path:
|
|
365
|
+
lines.append(f" LD_LIBRARY_PATH:")
|
|
366
|
+
for p in profile.ld_library_path:
|
|
367
|
+
lines.append(f" {p}")
|
|
368
|
+
else:
|
|
369
|
+
lines.append(f" LD_LIBRARY_PATH: {c.DIM}(not set){c.RESET}")
|
|
370
|
+
|
|
371
|
+
lines.append("")
|
|
372
|
+
lines.append(f" Site-packages:")
|
|
373
|
+
if profile.site_packages_paths:
|
|
374
|
+
for p in profile.site_packages_paths:
|
|
375
|
+
lines.append(f" {p}")
|
|
376
|
+
else:
|
|
377
|
+
lines.append(f" {c.DIM}(none found){c.RESET}")
|
|
378
|
+
|
|
379
|
+
# CPU flags (if verbose context)
|
|
380
|
+
if profile.cpu_flags:
|
|
381
|
+
lines.append("")
|
|
382
|
+
lines.append(f"{c.BOLD}CPU Features{c.RESET}")
|
|
383
|
+
flags_str = ", ".join(sorted(profile.cpu_flags)[:20])
|
|
384
|
+
if len(profile.cpu_flags) > 20:
|
|
385
|
+
flags_str += f" ... and {len(profile.cpu_flags) - 20} more"
|
|
386
|
+
lines.append(f" {flags_str}")
|
|
387
|
+
|
|
388
|
+
return "\n".join(lines)
|
|
389
|
+
|
|
390
|
+
def format_doctor(self, diagnosis: Dict[str, Any]) -> str:
|
|
391
|
+
"""Format a diagnostic result."""
|
|
392
|
+
c = self._c
|
|
393
|
+
lines: List[str] = []
|
|
394
|
+
|
|
395
|
+
lines.append(
|
|
396
|
+
f"{c.BOLD}PyBinaryGuard \u2014 Doctor{c.RESET}"
|
|
397
|
+
)
|
|
398
|
+
lines.append(f"{c.DIM}{_HEADER_LINE}{c.RESET}")
|
|
399
|
+
lines.append("")
|
|
400
|
+
|
|
401
|
+
if "error" in diagnosis:
|
|
402
|
+
lines.append(f"{c.BOLD}Diagnosing error:{c.RESET}")
|
|
403
|
+
lines.append(f" {diagnosis['error']}")
|
|
404
|
+
lines.append("")
|
|
405
|
+
|
|
406
|
+
if "package" in diagnosis:
|
|
407
|
+
lines.append(f"{c.BOLD}Package:{c.RESET} {diagnosis['package']}")
|
|
408
|
+
lines.append("")
|
|
409
|
+
|
|
410
|
+
if "findings" in diagnosis:
|
|
411
|
+
for finding in diagnosis["findings"]:
|
|
412
|
+
lines.extend(self._format_finding(finding, verbose=True))
|
|
413
|
+
lines.append("")
|
|
414
|
+
|
|
415
|
+
if "suggestions" in diagnosis:
|
|
416
|
+
lines.append(f"{c.BOLD}Suggestions:{c.RESET}")
|
|
417
|
+
for i, suggestion in enumerate(diagnosis["suggestions"], 1):
|
|
418
|
+
lines.append(f" {i}. {suggestion}")
|
|
419
|
+
|
|
420
|
+
if not diagnosis.get("findings") and not diagnosis.get("suggestions"):
|
|
421
|
+
lines.append(
|
|
422
|
+
f"{c.GREEN}No issues detected. The environment looks healthy.{c.RESET}"
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
return "\n".join(lines)
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def _score_bar(score: float, width: int = 10) -> str:
|
|
429
|
+
"""Render a text-based score bar: [======== ]."""
|
|
430
|
+
filled = round(score / 100.0 * width)
|
|
431
|
+
empty = width - filled
|
|
432
|
+
return "[" + "\u2588" * filled + " " * empty + "]"
|
|
433
|
+
|
|
434
|
+
def _format_finding(self, finding: Finding, verbose: bool) -> List[str]:
|
|
435
|
+
"""Format a single finding for table output."""
|
|
436
|
+
c = self._c
|
|
437
|
+
lines: List[str] = []
|
|
438
|
+
|
|
439
|
+
severity_str = _severity_label(finding.severity, c)
|
|
440
|
+
|
|
441
|
+
# First line: severity + package + version
|
|
442
|
+
header = severity_str
|
|
443
|
+
if finding.package:
|
|
444
|
+
version_part = f" {finding.package_version}" if finding.package_version else ""
|
|
445
|
+
header += f" {finding.package}{version_part}"
|
|
446
|
+
lines.append(header)
|
|
447
|
+
|
|
448
|
+
# Title
|
|
449
|
+
lines.append(f" {c.BOLD}{finding.title}{c.RESET}")
|
|
450
|
+
|
|
451
|
+
# Explanation
|
|
452
|
+
lines.append(f" {finding.explanation}")
|
|
453
|
+
|
|
454
|
+
# Verbose details
|
|
455
|
+
if verbose and finding.technical_detail:
|
|
456
|
+
lines.append(
|
|
457
|
+
f" {c.DIM}Detail: {finding.technical_detail}{c.RESET}"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
# Suggestion (always shown for CRITICAL)
|
|
461
|
+
if finding.suggestion and (verbose or finding.severity == Severity.CRITICAL):
|
|
462
|
+
lines.append(f" {c.CYAN}Fix: {finding.suggestion}{c.RESET}")
|
|
463
|
+
|
|
464
|
+
return lines
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
# JSONFormatter
|
|
469
|
+
# ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
class JSONFormatter(FormatterBase):
|
|
472
|
+
"""Machine-readable JSON output.
|
|
473
|
+
|
|
474
|
+
Produces a JSON object with top-level keys ``version``, ``profile``,
|
|
475
|
+
``scan``, and ``findings``.
|
|
476
|
+
"""
|
|
477
|
+
|
|
478
|
+
def format_scan(
|
|
479
|
+
self,
|
|
480
|
+
report: ScanReport,
|
|
481
|
+
profile: SystemProfile,
|
|
482
|
+
verbose: bool = False,
|
|
483
|
+
) -> str:
|
|
484
|
+
"""Format a full scan report as JSON."""
|
|
485
|
+
scan_data: Dict[str, Any] = {
|
|
486
|
+
"total_packages": report.total_packages,
|
|
487
|
+
"packages_scanned": report.packages_scanned,
|
|
488
|
+
"scan_duration_ms": round(report.scan_duration_ms, 1),
|
|
489
|
+
"health_score": report.health_score,
|
|
490
|
+
"health_label": report.health_label,
|
|
491
|
+
"summary": {
|
|
492
|
+
"critical": report.critical_count,
|
|
493
|
+
"warning": report.warning_count,
|
|
494
|
+
"info": report.info_count,
|
|
495
|
+
"passed": report.passed_count,
|
|
496
|
+
},
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
# Add detected board if available
|
|
500
|
+
if report.detected_board:
|
|
501
|
+
scan_data["detected_board"] = report.detected_board
|
|
502
|
+
|
|
503
|
+
# Add v2 score breakdown if available
|
|
504
|
+
if report.score_breakdown is not None:
|
|
505
|
+
scan_data["score_breakdown"] = report.score_breakdown.as_dict()
|
|
506
|
+
|
|
507
|
+
output: Dict[str, Any] = {
|
|
508
|
+
"version": "1.0.0",
|
|
509
|
+
"profile": self._profile_dict(profile),
|
|
510
|
+
"scan": scan_data,
|
|
511
|
+
"findings": [f.as_dict() for f in report.findings],
|
|
512
|
+
}
|
|
513
|
+
return json.dumps(output, indent=2, default=str)
|
|
514
|
+
|
|
515
|
+
def format_check(
|
|
516
|
+
self,
|
|
517
|
+
findings: List[Finding],
|
|
518
|
+
package: str,
|
|
519
|
+
profile: SystemProfile,
|
|
520
|
+
verbose: bool = False,
|
|
521
|
+
) -> str:
|
|
522
|
+
"""Format check findings as JSON."""
|
|
523
|
+
output: Dict[str, Any] = {
|
|
524
|
+
"version": "1.0.0",
|
|
525
|
+
"package": package,
|
|
526
|
+
"findings": [f.as_dict() for f in findings],
|
|
527
|
+
"summary": {
|
|
528
|
+
"critical": sum(1 for f in findings if f.severity == Severity.CRITICAL),
|
|
529
|
+
"warning": sum(1 for f in findings if f.severity == Severity.WARNING),
|
|
530
|
+
"info": sum(1 for f in findings if f.severity == Severity.INFO),
|
|
531
|
+
"passed": sum(1 for f in findings if f.severity == Severity.PASSED),
|
|
532
|
+
},
|
|
533
|
+
}
|
|
534
|
+
return json.dumps(output, indent=2, default=str)
|
|
535
|
+
|
|
536
|
+
def format_profile(self, profile: SystemProfile) -> str:
|
|
537
|
+
"""Format a system profile as JSON."""
|
|
538
|
+
output: Dict[str, Any] = {
|
|
539
|
+
"version": "1.0.0",
|
|
540
|
+
"profile": self._profile_dict(profile),
|
|
541
|
+
}
|
|
542
|
+
return json.dumps(output, indent=2, default=str)
|
|
543
|
+
|
|
544
|
+
def format_doctor(self, diagnosis: Dict[str, Any]) -> str:
|
|
545
|
+
"""Format a doctor result as JSON."""
|
|
546
|
+
output: Dict[str, Any] = {
|
|
547
|
+
"version": "1.0.0",
|
|
548
|
+
"diagnosis": {},
|
|
549
|
+
}
|
|
550
|
+
if "error" in diagnosis:
|
|
551
|
+
output["diagnosis"]["error"] = diagnosis["error"]
|
|
552
|
+
if "package" in diagnosis:
|
|
553
|
+
output["diagnosis"]["package"] = diagnosis["package"]
|
|
554
|
+
if "findings" in diagnosis:
|
|
555
|
+
output["diagnosis"]["findings"] = [
|
|
556
|
+
f.as_dict() for f in diagnosis["findings"]
|
|
557
|
+
]
|
|
558
|
+
if "suggestions" in diagnosis:
|
|
559
|
+
output["diagnosis"]["suggestions"] = diagnosis["suggestions"]
|
|
560
|
+
return json.dumps(output, indent=2, default=str)
|
|
561
|
+
|
|
562
|
+
@staticmethod
|
|
563
|
+
def _profile_dict(profile: SystemProfile) -> Dict[str, Any]:
|
|
564
|
+
"""Convert a SystemProfile to a JSON-serializable dict."""
|
|
565
|
+
py_ver = ".".join(str(v) for v in profile.python_version)
|
|
566
|
+
result: Dict[str, Any] = {
|
|
567
|
+
"python": {
|
|
568
|
+
"version": py_ver,
|
|
569
|
+
"abi_tag": profile.python_abi_tag,
|
|
570
|
+
"implementation": profile.python_implementation,
|
|
571
|
+
"executable": profile.python_executable,
|
|
572
|
+
"debug_build": profile.python_debug_build,
|
|
573
|
+
},
|
|
574
|
+
"os": {
|
|
575
|
+
"name": profile.os_name,
|
|
576
|
+
"version": profile.os_version,
|
|
577
|
+
"kernel": profile.kernel_version,
|
|
578
|
+
},
|
|
579
|
+
"architecture": profile.architecture.value,
|
|
580
|
+
}
|
|
581
|
+
if profile.glibc_version:
|
|
582
|
+
result["glibc_version"] = (
|
|
583
|
+
f"{profile.glibc_version[0]}.{profile.glibc_version[1]}"
|
|
584
|
+
)
|
|
585
|
+
if profile.musl_version:
|
|
586
|
+
result["musl_version"] = (
|
|
587
|
+
f"{profile.musl_version[0]}.{profile.musl_version[1]}"
|
|
588
|
+
)
|
|
589
|
+
if profile.gpu_available:
|
|
590
|
+
result["gpu"] = {
|
|
591
|
+
"driver_version": profile.gpu_driver_version,
|
|
592
|
+
"name": profile.gpu_name,
|
|
593
|
+
}
|
|
594
|
+
if profile.cuda_runtime_version:
|
|
595
|
+
result["gpu"]["cuda_runtime"] = (
|
|
596
|
+
f"{profile.cuda_runtime_version[0]}.{profile.cuda_runtime_version[1]}"
|
|
597
|
+
)
|
|
598
|
+
if profile.is_container:
|
|
599
|
+
result["container"] = profile.container_runtime.value
|
|
600
|
+
if profile.is_embedded_board:
|
|
601
|
+
result["board"] = profile.board_name
|
|
602
|
+
return result
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
# ---------------------------------------------------------------------------
|
|
606
|
+
# MinimalFormatter
|
|
607
|
+
# ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
class MinimalFormatter(FormatterBase):
|
|
610
|
+
"""One-line-per-finding output.
|
|
611
|
+
|
|
612
|
+
Example: ``CRITICAL: torch 2.1.0 - CUDA version mismatch``
|
|
613
|
+
"""
|
|
614
|
+
|
|
615
|
+
def format_scan(
|
|
616
|
+
self,
|
|
617
|
+
report: ScanReport,
|
|
618
|
+
profile: SystemProfile,
|
|
619
|
+
verbose: bool = False,
|
|
620
|
+
) -> str:
|
|
621
|
+
"""Format a scan report with one line per finding."""
|
|
622
|
+
lines: List[str] = []
|
|
623
|
+
for finding in report.findings:
|
|
624
|
+
lines.append(self._format_finding_line(finding))
|
|
625
|
+
|
|
626
|
+
# Summary line
|
|
627
|
+
lines.append(
|
|
628
|
+
f"--- {report.critical_count} critical, "
|
|
629
|
+
f"{report.warning_count} warning, "
|
|
630
|
+
f"{report.info_count} info | "
|
|
631
|
+
f"score: {report.health_score}/100"
|
|
632
|
+
)
|
|
633
|
+
return "\n".join(lines)
|
|
634
|
+
|
|
635
|
+
def format_check(
|
|
636
|
+
self,
|
|
637
|
+
findings: List[Finding],
|
|
638
|
+
package: str,
|
|
639
|
+
profile: SystemProfile,
|
|
640
|
+
verbose: bool = False,
|
|
641
|
+
) -> str:
|
|
642
|
+
"""Format check findings with one line per finding."""
|
|
643
|
+
if not findings:
|
|
644
|
+
return f"OK: {package} - no issues found"
|
|
645
|
+
lines: List[str] = []
|
|
646
|
+
for finding in findings:
|
|
647
|
+
lines.append(self._format_finding_line(finding))
|
|
648
|
+
return "\n".join(lines)
|
|
649
|
+
|
|
650
|
+
def format_profile(self, profile: SystemProfile) -> str:
|
|
651
|
+
"""Format a minimal profile summary."""
|
|
652
|
+
parts: List[str] = []
|
|
653
|
+
for key, value in profile.summary().items():
|
|
654
|
+
parts.append(f"{key}: {value}")
|
|
655
|
+
return " | ".join(parts)
|
|
656
|
+
|
|
657
|
+
def format_doctor(self, diagnosis: Dict[str, Any]) -> str:
|
|
658
|
+
"""Format a minimal doctor result."""
|
|
659
|
+
lines: List[str] = []
|
|
660
|
+
if "error" in diagnosis:
|
|
661
|
+
lines.append(f"Error: {diagnosis['error']}")
|
|
662
|
+
if "findings" in diagnosis:
|
|
663
|
+
for finding in diagnosis["findings"]:
|
|
664
|
+
lines.append(self._format_finding_line(finding))
|
|
665
|
+
if "suggestions" in diagnosis:
|
|
666
|
+
for suggestion in diagnosis["suggestions"]:
|
|
667
|
+
lines.append(f"Suggestion: {suggestion}")
|
|
668
|
+
if not lines:
|
|
669
|
+
lines.append("OK: no issues detected")
|
|
670
|
+
return "\n".join(lines)
|
|
671
|
+
|
|
672
|
+
@staticmethod
|
|
673
|
+
def _format_finding_line(finding: Finding) -> str:
|
|
674
|
+
"""Format a single finding as one line."""
|
|
675
|
+
label = _plain_severity_label(finding.severity)
|
|
676
|
+
parts = [f"{label}:"]
|
|
677
|
+
if finding.package:
|
|
678
|
+
version_part = f" {finding.package_version}" if finding.package_version else ""
|
|
679
|
+
parts.append(f"{finding.package}{version_part}")
|
|
680
|
+
parts.append("-")
|
|
681
|
+
parts.append(finding.title)
|
|
682
|
+
return " ".join(parts)
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
# ---------------------------------------------------------------------------
|
|
686
|
+
# Factory function
|
|
687
|
+
# ---------------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
def get_formatter(name: str, no_color: bool = False) -> FormatterBase:
|
|
690
|
+
"""Return a formatter instance by name.
|
|
691
|
+
|
|
692
|
+
Parameters
|
|
693
|
+
----------
|
|
694
|
+
name:
|
|
695
|
+
One of ``"table"``, ``"json"``, or ``"minimal"``.
|
|
696
|
+
no_color:
|
|
697
|
+
Disable ANSI colour codes (relevant for ``TableFormatter``).
|
|
698
|
+
|
|
699
|
+
Returns
|
|
700
|
+
-------
|
|
701
|
+
FormatterBase
|
|
702
|
+
The requested formatter.
|
|
703
|
+
|
|
704
|
+
Raises
|
|
705
|
+
------
|
|
706
|
+
ValueError
|
|
707
|
+
If *name* is not a recognised formatter name.
|
|
708
|
+
"""
|
|
709
|
+
formatters = {
|
|
710
|
+
"table": lambda: TableFormatter(no_color=no_color),
|
|
711
|
+
"json": lambda: JSONFormatter(),
|
|
712
|
+
"minimal": lambda: MinimalFormatter(),
|
|
713
|
+
}
|
|
714
|
+
factory = formatters.get(name)
|
|
715
|
+
if factory is None:
|
|
716
|
+
raise ValueError(
|
|
717
|
+
f"Unknown formatter: {name!r}. "
|
|
718
|
+
f"Available: {', '.join(sorted(formatters))}"
|
|
719
|
+
)
|
|
720
|
+
return factory()
|