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.
Files changed (91) hide show
  1. pybinaryguard/__init__.py +78 -0
  2. pybinaryguard/__main__.py +7 -0
  3. pybinaryguard/_compat/__init__.py +0 -0
  4. pybinaryguard/agent/__init__.py +63 -0
  5. pybinaryguard/agent/guard.py +232 -0
  6. pybinaryguard/agent/recommender.py +283 -0
  7. pybinaryguard/agent/schema.py +200 -0
  8. pybinaryguard/agent/simulator.py +430 -0
  9. pybinaryguard/agent/tool_interface.py +474 -0
  10. pybinaryguard/analyzers/__init__.py +209 -0
  11. pybinaryguard/analyzers/base.py +40 -0
  12. pybinaryguard/analyzers/dependency_analyzer.py +336 -0
  13. pybinaryguard/analyzers/elf_analyzer.py +754 -0
  14. pybinaryguard/analyzers/symbol_analyzer.py +280 -0
  15. pybinaryguard/analyzers/wheel_analyzer.py +308 -0
  16. pybinaryguard/cli/__init__.py +5 -0
  17. pybinaryguard/cli/commands.py +414 -0
  18. pybinaryguard/cli/formatters.py +720 -0
  19. pybinaryguard/cli/main.py +250 -0
  20. pybinaryguard/diagnostics/__init__.py +13 -0
  21. pybinaryguard/diagnostics/explainer.py +356 -0
  22. pybinaryguard/diagnostics/findings.py +146 -0
  23. pybinaryguard/diagnostics/suggestions.py +508 -0
  24. pybinaryguard/frameworks/__init__.py +20 -0
  25. pybinaryguard/frameworks/onnxruntime.py +214 -0
  26. pybinaryguard/frameworks/pytorch.py +223 -0
  27. pybinaryguard/frameworks/tensorflow.py +266 -0
  28. pybinaryguard/frameworks/tensorrt.py +189 -0
  29. pybinaryguard/models/__init__.py +19 -0
  30. pybinaryguard/models/enums.py +102 -0
  31. pybinaryguard/models/finding.py +109 -0
  32. pybinaryguard/models/package.py +121 -0
  33. pybinaryguard/models/system.py +118 -0
  34. pybinaryguard/plugins/__init__.py +19 -0
  35. pybinaryguard/plugins/contrib/__init__.py +7 -0
  36. pybinaryguard/plugins/contrib/gstreamer.py +109 -0
  37. pybinaryguard/plugins/contrib/jetson.py +385 -0
  38. pybinaryguard/plugins/contrib/opencv.py +306 -0
  39. pybinaryguard/plugins/contrib/tensorrt.py +426 -0
  40. pybinaryguard/plugins/hooks.py +273 -0
  41. pybinaryguard/plugins/loader.py +190 -0
  42. pybinaryguard/predictor/__init__.py +23 -0
  43. pybinaryguard/predictor/dependency_graph.py +226 -0
  44. pybinaryguard/predictor/linker_simulator.py +161 -0
  45. pybinaryguard/predictor/predictor.py +144 -0
  46. pybinaryguard/predictor/resolver.py +282 -0
  47. pybinaryguard/probes/__init__.py +73 -0
  48. pybinaryguard/probes/base.py +45 -0
  49. pybinaryguard/probes/board_probe.py +248 -0
  50. pybinaryguard/probes/cpu_probe.py +176 -0
  51. pybinaryguard/probes/glibc_probe.py +215 -0
  52. pybinaryguard/probes/gpu_probe.py +430 -0
  53. pybinaryguard/probes/library_probe.py +132 -0
  54. pybinaryguard/probes/os_probe.py +227 -0
  55. pybinaryguard/probes/python_probe.py +135 -0
  56. pybinaryguard/probes/toolchain_probe.py +89 -0
  57. pybinaryguard/probes/venv_probe.py +111 -0
  58. pybinaryguard/profiles/__init__.py +12 -0
  59. pybinaryguard/profiles/engine.py +343 -0
  60. pybinaryguard/rules/__init__.py +24 -0
  61. pybinaryguard/rules/base.py +57 -0
  62. pybinaryguard/rules/builtin/__init__.py +136 -0
  63. pybinaryguard/rules/builtin/arch_rules.py +86 -0
  64. pybinaryguard/rules/builtin/board_profile_rules.py +282 -0
  65. pybinaryguard/rules/builtin/container_rules.py +170 -0
  66. pybinaryguard/rules/builtin/cpu_rules.py +204 -0
  67. pybinaryguard/rules/builtin/cuda_rules.py +760 -0
  68. pybinaryguard/rules/builtin/dependency_rules.py +278 -0
  69. pybinaryguard/rules/builtin/framework_rules.py +252 -0
  70. pybinaryguard/rules/builtin/glibc_rules.py +320 -0
  71. pybinaryguard/rules/builtin/numpy_rules.py +110 -0
  72. pybinaryguard/rules/builtin/predictive_rules.py +165 -0
  73. pybinaryguard/rules/builtin/python_abi_rules.py +259 -0
  74. pybinaryguard/rules/builtin/source_build_rules.py +150 -0
  75. pybinaryguard/rules/builtin/venv_rules.py +159 -0
  76. pybinaryguard/rules/engine.py +123 -0
  77. pybinaryguard/scanner.py +904 -0
  78. pybinaryguard/scoring/__init__.py +19 -0
  79. pybinaryguard/scoring/engine.py +416 -0
  80. pybinaryguard/snapshot/__init__.py +20 -0
  81. pybinaryguard/snapshot/generator.py +168 -0
  82. pybinaryguard/snapshot/lockfile.py +152 -0
  83. pybinaryguard/snapshot/verifier.py +315 -0
  84. pybinaryguard/validators/__init__.py +7 -0
  85. pybinaryguard/validators/import_validator.py +231 -0
  86. pybinaryguard-1.0.0.dist-info/METADATA +876 -0
  87. pybinaryguard-1.0.0.dist-info/RECORD +91 -0
  88. pybinaryguard-1.0.0.dist-info/WHEEL +5 -0
  89. pybinaryguard-1.0.0.dist-info/entry_points.txt +8 -0
  90. pybinaryguard-1.0.0.dist-info/licenses/LICENSE +21 -0
  91. 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()