gpu-stack-inspector 0.1.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 (41) hide show
  1. gpu_stack_inspector/__init__.py +8 -0
  2. gpu_stack_inspector/__main__.py +6 -0
  3. gpu_stack_inspector/cli.py +335 -0
  4. gpu_stack_inspector/collectors/__init__.py +1 -0
  5. gpu_stack_inspector/collectors/environment.py +31 -0
  6. gpu_stack_inspector/collectors/host.py +48 -0
  7. gpu_stack_inspector/collectors/nvidia.py +166 -0
  8. gpu_stack_inspector/collectors/python_env.py +63 -0
  9. gpu_stack_inspector/collectors/toolkit.py +160 -0
  10. gpu_stack_inspector/compatibility/__init__.py +1 -0
  11. gpu_stack_inspector/compatibility/data.json +24 -0
  12. gpu_stack_inspector/compatibility/loader.py +75 -0
  13. gpu_stack_inspector/compatibility/versions.py +25 -0
  14. gpu_stack_inspector/exit_codes.py +22 -0
  15. gpu_stack_inspector/models.py +349 -0
  16. gpu_stack_inspector/orchestrator.py +132 -0
  17. gpu_stack_inspector/privacy.py +237 -0
  18. gpu_stack_inspector/probes/__init__.py +1 -0
  19. gpu_stack_inspector/probes/metadata_probe.py +230 -0
  20. gpu_stack_inspector/probes/metadata_program.py +232 -0
  21. gpu_stack_inspector/probes/runtime_probe.py +153 -0
  22. gpu_stack_inspector/probes/runtime_program.py +101 -0
  23. gpu_stack_inspector/reports/__init__.py +1 -0
  24. gpu_stack_inspector/reports/json_report.py +43 -0
  25. gpu_stack_inspector/reports/markdown.py +182 -0
  26. gpu_stack_inspector/reports/terminal.py +298 -0
  27. gpu_stack_inspector/rules/__init__.py +1 -0
  28. gpu_stack_inspector/rules/catalog.py +21 -0
  29. gpu_stack_inspector/rules/comparison.py +73 -0
  30. gpu_stack_inspector/rules/compatibility.py +256 -0
  31. gpu_stack_inspector/rules/differences.py +64 -0
  32. gpu_stack_inspector/rules/engine.py +101 -0
  33. gpu_stack_inspector/rules/host.py +259 -0
  34. gpu_stack_inspector/rules/interpreters.py +422 -0
  35. gpu_stack_inspector/rules/torch_build.py +227 -0
  36. gpu_stack_inspector/runner.py +273 -0
  37. gpu_stack_inspector-0.1.0.dist-info/METADATA +134 -0
  38. gpu_stack_inspector-0.1.0.dist-info/RECORD +41 -0
  39. gpu_stack_inspector-0.1.0.dist-info/WHEEL +4 -0
  40. gpu_stack_inspector-0.1.0.dist-info/entry_points.txt +3 -0
  41. gpu_stack_inspector-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,182 @@
1
+ """Markdown output: an issue-ready dossier of an already redacted snapshot.
2
+
3
+ Collected text is escaped so a path containing ``|`` cannot break a table and ``<tag>``
4
+ cannot become HTML in an issue tracker; values go in code spans whose fence is longer
5
+ than any backtick run they contain.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections import Counter
12
+
13
+ from gpu_stack_inspector.models import Finding, InterpreterFacts, Snapshot, Status
14
+ from gpu_stack_inspector.reports.terminal import redaction_line, runtime_summary
15
+ from gpu_stack_inspector.rules.comparison import torch_build_label
16
+
17
+ _SPECIAL = re.compile(r"([\\`*_\[\]<>|#])")
18
+ _PROBLEMS = (Status.FAIL, Status.WARN, Status.UNKNOWN)
19
+ _LABEL = {
20
+ Status.FAIL: "FAIL",
21
+ Status.WARN: "WARN",
22
+ Status.UNKNOWN: "UNKNOWN",
23
+ Status.PASS: "PASS",
24
+ Status.NOT_APPLICABLE: "N/A",
25
+ Status.NOT_TESTED: "NOT TESTED",
26
+ }
27
+
28
+
29
+ def text(value: str) -> str:
30
+ """Plain text that Markdown and HTML will not interpret; one line."""
31
+ return _SPECIAL.sub(r"\\\1", " ".join(value.split()))
32
+
33
+
34
+ def code(value: str) -> str:
35
+ """An inline code span that survives any backticks inside the value."""
36
+ flat = " ".join(value.split())
37
+ longest = max((len(run) for run in re.findall(r"`+", flat)), default=0)
38
+ fence = "`" * (longest + 1)
39
+ padding = " " if flat.startswith("`") or flat.endswith("`") else ""
40
+ return f"{fence}{padding}{flat}{padding}{fence}"
41
+
42
+
43
+ def _cell(value: str | None, *, as_code: bool = False) -> str:
44
+ if value is None:
45
+ return "—"
46
+ return code(value) if as_code else text(value)
47
+
48
+
49
+ def render_markdown(snapshot: Snapshot) -> str:
50
+ lines: list[str] = ["# GPU Stack Inspector report", ""]
51
+ lines.append(
52
+ f"Generated by gpu-stack-inspector {text(snapshot.tool_version)} on "
53
+ f"{text(snapshot.created_at)}. Read-only: nothing was installed or changed."
54
+ )
55
+ redacted = redaction_line(snapshot)
56
+ if redacted:
57
+ lines.append(f"{text(redacted)}.")
58
+ lines += ["", *_summary(snapshot), "", *_system(snapshot)]
59
+ if snapshot.interpreters:
60
+ lines += ["", *_environments(snapshot.interpreters)]
61
+ if snapshot.comparison:
62
+ lines += ["", *_differences(snapshot)]
63
+ if snapshot.findings:
64
+ lines += ["", *_findings(snapshot)]
65
+ if snapshot.probe_failures:
66
+ lines += ["", "## Not checked", ""]
67
+ lines += [f"- {text(f.probe)}: {text(f.detail)}" for f in snapshot.probe_failures]
68
+ return "\n".join(lines) + "\n"
69
+
70
+
71
+ def _summary(snapshot: Snapshot) -> list[str]:
72
+ lines = ["## Summary", ""]
73
+ problems = [f for f in snapshot.findings if f.status in _PROBLEMS and not f.caused_by]
74
+ order = {status: i for i, status in enumerate(_PROBLEMS)}
75
+ for finding in sorted(problems, key=lambda f: order[f.status]):
76
+ where = "" if finding.subject == "host" else f" ({text(finding.subject)})"
77
+ lines.append(
78
+ f"- **{_LABEL[finding.status]}** {code(finding.id)}{where}: {text(finding.summary)}"
79
+ )
80
+ if not problems:
81
+ lines.append("No problems found." if snapshot.findings else "No checks were run.")
82
+ counts = Counter(f.status for f in snapshot.findings)
83
+ if snapshot.findings:
84
+ lines += [
85
+ "",
86
+ f"{counts[Status.FAIL]} failed, {counts[Status.WARN]} warnings, "
87
+ f"{counts[Status.UNKNOWN]} unknown, {counts[Status.PASS]} passed.",
88
+ ]
89
+ return lines
90
+
91
+
92
+ def _system(snapshot: Snapshot) -> list[str]:
93
+ rows: list[tuple[str, str]] = []
94
+ host = snapshot.host
95
+ if host is not None:
96
+ system = " · ".join(p for p in (f"{host.system} {host.machine}", host.distribution) if p)
97
+ rows.append(("OS", text(system)))
98
+ rows.append(("Kernel", _cell(host.kernel)))
99
+ nvidia = snapshot.nvidia
100
+ if nvidia is not None:
101
+ if nvidia.smi_path is None:
102
+ rows.append(("NVIDIA driver", "nvidia-smi not found"))
103
+ else:
104
+ driver = _cell(nvidia.driver_version)
105
+ cuda = _cell(nvidia.max_cuda_version)
106
+ rows.append(("NVIDIA driver", f"{driver} (supports CUDA up to {cuda})"))
107
+ for gpu in nvidia.gpus:
108
+ memory = f"{gpu.memory_total_mib} MiB" if gpu.memory_total_mib is not None else "? MiB"
109
+ details = f"{text(gpu.name)}, {memory}, compute {_cell(gpu.compute_capability)}"
110
+ rows.append((f"GPU {gpu.index}", details))
111
+ toolkit = snapshot.toolkit
112
+ if toolkit is not None:
113
+ rows.append(("nvcc", _cell(toolkit.nvcc_version) if toolkit.nvcc_path else "not installed"))
114
+ for found in toolkit.toolkits:
115
+ rows.append(("CUDA toolkit", f"{code(found.path)} ({_cell(found.version)})"))
116
+ for variable in snapshot.cuda_environment:
117
+ rows.append((text(variable.name), code(variable.value) if variable.value else '`""`'))
118
+ if not rows:
119
+ return ["## System", "", "Not inspected."]
120
+ return ["## System", "", "| | |", "| --- | --- |", *(f"| {k} | {v} |" for k, v in rows)]
121
+
122
+
123
+ def _environments(interpreters: tuple[InterpreterFacts, ...]) -> list[str]:
124
+ lines = [
125
+ "## Environments",
126
+ "",
127
+ "| | Path | Python | Kind | torch | Build | Runtime |",
128
+ "| --- | --- | --- | --- | --- | --- | --- |",
129
+ ]
130
+ for i in interpreters:
131
+ kind = ", ".join(k for k, on in (("conda", i.conda), ("venv", i.virtualenv)) if on) or "—"
132
+ torch = i.torch.version if i.torch else None
133
+ runtime = runtime_summary(i) or "not tested"
134
+ lines.append(
135
+ f"| {text(i.label)} | {code(i.path)} | {_cell(i.version)} | {kind} "
136
+ f"| {_cell(torch)} | {_cell(torch_build_label(i))} | {text(runtime)} |"
137
+ )
138
+ return lines
139
+
140
+
141
+ def _differences(snapshot: Snapshot) -> list[str]:
142
+ rows = [row for row in snapshot.comparison if row.differs]
143
+ labels = [text(i.label) for i in snapshot.interpreters]
144
+ lines = ["## Differences", ""]
145
+ if not rows:
146
+ return [*lines, "No differences in the compared fields."]
147
+ lines.append("| Field | " + " | ".join(labels) + " |")
148
+ lines.append("| --- |" + " --- |" * len(labels))
149
+ for row in rows:
150
+ values = " | ".join(_cell(value) for value in row.values)
151
+ lines.append(f"| {text(row.field)} | {values} |")
152
+ return lines
153
+
154
+
155
+ def _findings(snapshot: Snapshot) -> list[str]:
156
+ paths = {i.label: i.path for i in snapshot.interpreters}
157
+ lines = ["## Findings"]
158
+ for subject in ["host", *paths, "comparison"]:
159
+ group = [f for f in snapshot.findings if f.subject == subject]
160
+ if not group:
161
+ continue
162
+ heading = {"host": "Host", "comparison": "Comparison"}.get(subject)
163
+ lines += ["", f"### {heading or f'{text(subject)} ({code(paths[subject])})'}", ""]
164
+ for finding in group:
165
+ lines += _finding(finding)
166
+ return lines
167
+
168
+
169
+ def _finding(finding: Finding) -> list[str]:
170
+ summary = text(finding.summary)
171
+ if finding.caused_by:
172
+ summary += f" (follows from {code(finding.caused_by)})"
173
+ lines = [f"- **{_LABEL[finding.status]}** {code(finding.id)} {summary}"]
174
+ if finding.status not in _PROBLEMS:
175
+ return lines
176
+ lines.append(f" - Why: {text(finding.reason)}")
177
+ if finding.recommended_action:
178
+ lines.append(f" - Next: {text(finding.recommended_action)}")
179
+ if finding.verify_command:
180
+ lines.append(f" - Check: {code(finding.verify_command)}")
181
+ lines += [f" - Note: {text(limitation)}" for limitation in finding.limitations]
182
+ return lines
@@ -0,0 +1,298 @@
1
+ """Terminal output: a short human summary of an already redacted snapshot.
2
+
3
+ Collected text is always added as plain ``Text``, never as Rich markup, so a path or GPU
4
+ name containing ``[bold]`` is printed as it is instead of being interpreted.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections import Counter
10
+
11
+ from rich.console import Console
12
+ from rich.table import Table
13
+ from rich.text import Text
14
+
15
+ from gpu_stack_inspector.models import (
16
+ Finding,
17
+ HostFacts,
18
+ InterpreterFacts,
19
+ NvidiaFacts,
20
+ Snapshot,
21
+ Status,
22
+ ToolkitFacts,
23
+ )
24
+ from gpu_stack_inspector.rules.torch_build import TorchBuild, TorchBuildKind, classify_torch
25
+
26
+ _INDENT = " "
27
+
28
+
29
+ def _render_header(snapshot: Snapshot, console: Console) -> None:
30
+ console.print(Text(f"GPU Stack Inspector {snapshot.tool_version}", style="bold"))
31
+ console.print(_row("Host", _host(snapshot.host)))
32
+ nvidia_lines = _nvidia(snapshot.nvidia)
33
+ console.print(_row("NVIDIA", nvidia_lines[0]))
34
+ for line in nvidia_lines[1:]:
35
+ console.print(Text(_INDENT + line))
36
+ console.print(_row("Toolkit", _toolkit(snapshot.toolkit)))
37
+
38
+
39
+ _REDACTION_NAMES = {
40
+ "home_path": ("home path", "home paths"),
41
+ "user_name": ("user name", "user names"),
42
+ "host_name": ("host name", "host names"),
43
+ "secret_value": ("secret value", "secret values"),
44
+ "url_credentials": ("URL credential", "URL credentials"),
45
+ "token": ("token", "tokens"),
46
+ }
47
+
48
+
49
+ def redaction_line(snapshot: Snapshot) -> str | None:
50
+ """'Redacted from this output: 3 home paths, 1 secret value', or None if nothing was."""
51
+ if not snapshot.redactions:
52
+ return None
53
+ parts = []
54
+ for item in snapshot.redactions:
55
+ singular, plural = _REDACTION_NAMES.get(item.kind, (item.kind, item.kind))
56
+ parts.append(f"{item.count} {singular if item.count == 1 else plural}")
57
+ return "Redacted from this output: " + ", ".join(parts)
58
+
59
+
60
+ def _render_failures(snapshot: Snapshot, console: Console) -> None:
61
+ if snapshot.probe_failures:
62
+ console.print()
63
+ console.print(Text("Could not check:", style="bold"))
64
+ for failure in snapshot.probe_failures:
65
+ console.print(Text(f" {failure.probe}: {failure.detail}"))
66
+ line = redaction_line(snapshot)
67
+ if line:
68
+ console.print(Text(line, style="dim"))
69
+
70
+
71
+ def render_compare(snapshot: Snapshot, console: Console, *, show_all: bool = False) -> None:
72
+ """The comparison table (only the fields that differ, unless ``show_all``), then findings."""
73
+ _render_header(snapshot, console)
74
+ console.print()
75
+ for interpreter in snapshot.interpreters:
76
+ console.print(Text.assemble((f"{interpreter.label:<3}", "bold"), interpreter.path))
77
+ rows = [row for row in snapshot.comparison if show_all or row.differs]
78
+ console.print()
79
+ if not rows:
80
+ console.print(Text("No differences in the compared fields."))
81
+ else:
82
+ table = Table(box=None, pad_edge=False, show_edge=False)
83
+ table.add_column("Field", no_wrap=True)
84
+ for interpreter in snapshot.interpreters:
85
+ table.add_column(interpreter.label, overflow="fold")
86
+ for row in rows:
87
+ style = "bold" if row.differs else "dim"
88
+ values = [Text(value if value is not None else "—") for value in row.values]
89
+ table.add_row(Text(row.field, style=style), *values)
90
+ console.print(table)
91
+ hidden = len(snapshot.comparison) - len(rows)
92
+ if hidden:
93
+ console.print(Text(f"{hidden} identical fields hidden; --all shows them.", style="dim"))
94
+ if snapshot.findings:
95
+ _render_findings(snapshot, console)
96
+ _render_failures(snapshot, console)
97
+
98
+
99
+ def render_terminal(snapshot: Snapshot, console: Console) -> None:
100
+ _render_header(snapshot, console)
101
+ if snapshot.cuda_environment:
102
+ values = ", ".join(
103
+ f"{v.name}={v.value}" if v.value else f'{v.name}="" (hides every GPU)'
104
+ for v in snapshot.cuda_environment
105
+ )
106
+ console.print(_row("CUDA env", values))
107
+ for interpreter in snapshot.interpreters:
108
+ console.print()
109
+ console.print(Text.assemble((f"{interpreter.label:<3}", "bold"), interpreter.path))
110
+ console.print(Text(" " + _interpreter(interpreter)))
111
+ if snapshot.findings:
112
+ _render_findings(snapshot, console)
113
+ _render_failures(snapshot, console)
114
+
115
+
116
+ def _row(name: str, value: str) -> Text:
117
+ return Text.assemble((f"{name:<9}", "bold"), value)
118
+
119
+
120
+ def _host(host: HostFacts | None) -> str:
121
+ if host is None:
122
+ return "not inspected"
123
+ parts = [f"{host.system} {host.machine}"]
124
+ if host.distribution:
125
+ parts.append(host.distribution)
126
+ if host.kernel:
127
+ parts.append(f"kernel {host.kernel}")
128
+ if host.wsl:
129
+ parts.append("WSL")
130
+ return " · ".join(parts)
131
+
132
+
133
+ def _nvidia(nvidia: NvidiaFacts | None) -> list[str]:
134
+ if nvidia is None:
135
+ return ["not inspected"]
136
+ if nvidia.smi_path is None:
137
+ return ["nvidia-smi not found (no NVIDIA driver tools on PATH)"]
138
+ if nvidia.driver_version is None and not nvidia.gpus:
139
+ return ["nvidia-smi did not answer (see below)"]
140
+ summary = (
141
+ f"driver {nvidia.driver_version or 'unknown'} · "
142
+ f"supports CUDA up to {nvidia.max_cuda_version or 'unknown'}"
143
+ )
144
+ gpus = [
145
+ f"GPU {gpu.index}: {gpu.name}"
146
+ + f" ({gpu.memory_total_mib if gpu.memory_total_mib is not None else '?'} MiB,"
147
+ + f" compute {gpu.compute_capability or 'unknown'})"
148
+ for gpu in nvidia.gpus
149
+ ]
150
+ return [summary, *(gpus or ["no GPU visible to the driver"])]
151
+
152
+
153
+ def _toolkit(toolkit: ToolkitFacts | None) -> str:
154
+ if toolkit is None:
155
+ return "not inspected"
156
+ nvcc = (
157
+ f"nvcc {toolkit.nvcc_version or 'unknown version'} ({toolkit.nvcc_path})"
158
+ if toolkit.nvcc_path
159
+ else "no nvcc on PATH"
160
+ )
161
+ directories = ", ".join(
162
+ f"{t.path} ({t.version or 'unknown version'})" for t in toolkit.toolkits
163
+ )
164
+ return f"{nvcc} · {directories or 'no CUDA toolkit directories found'}"
165
+
166
+
167
+ def _interpreter(interpreter: InterpreterFacts) -> str:
168
+ if interpreter.version is None:
169
+ return "could not be inspected (see below)"
170
+ kind = [interpreter.implementation or "python"]
171
+ if interpreter.conda:
172
+ kind.append("conda")
173
+ if interpreter.virtualenv:
174
+ kind.append("venv")
175
+ wheels = sum(d.name.startswith("nvidia-") for d in interpreter.distributions)
176
+ parts = [
177
+ f"Python {interpreter.version} ({', '.join(kind)})",
178
+ _torch(interpreter),
179
+ f"{wheels} nvidia-* wheels",
180
+ ]
181
+ runtime = runtime_summary(interpreter)
182
+ if runtime:
183
+ parts.append(runtime)
184
+ return " · ".join(parts)
185
+
186
+
187
+ def runtime_summary(interpreter: InterpreterFacts) -> str | None:
188
+ """What probe --runtime found, in a few words; None when it was not run."""
189
+ runtime = interpreter.runtime
190
+ if runtime is None:
191
+ return None
192
+ if runtime.tensor_ok:
193
+ device = runtime.devices[0].name if runtime.devices else "the GPU"
194
+ return f"CUDA works on {device}"
195
+ if runtime.cuda_available is False:
196
+ return "CUDA unavailable"
197
+ failed = next((step.step for step in runtime.steps if not step.ok), None)
198
+ return f"runtime check failed at '{failed}'" if failed else "runtime check failed"
199
+
200
+
201
+ def _torch(interpreter: InterpreterFacts) -> str:
202
+ if interpreter.torch is None:
203
+ listed = next((d for d in interpreter.distributions if d.name == "torch"), None)
204
+ return f"torch {listed.version} (build not inspected)" if listed else "no torch"
205
+ return f"torch {interpreter.torch.version} ({_build(classify_torch(interpreter.torch))})"
206
+
207
+
208
+ def _build(build: TorchBuild) -> str:
209
+ """Say what the metadata shows, and only that: no CUDA operation has run yet."""
210
+ if build.kind is TorchBuildKind.CUDA:
211
+ target = f"CUDA {build.cuda_version}" if build.cuda_version else "CUDA"
212
+ text = f"{target} build"
213
+ elif build.kind is TorchBuildKind.CPU:
214
+ text = "CPU-only build"
215
+ elif build.kind is TorchBuildKind.ROCM:
216
+ text = "ROCm build, not checked by this tool"
217
+ else:
218
+ text = "build unknown"
219
+ if build.conflicts:
220
+ text += f"; {build.conflicts[0]}"
221
+ elif build.kind is not TorchBuildKind.UNKNOWN and build.confidence.value != "high":
222
+ text += f", {build.confidence.value} confidence"
223
+ return text
224
+
225
+
226
+ _LABELS = {
227
+ Status.FAIL: ("FAIL", "bold red"),
228
+ Status.WARN: ("WARN", "yellow"),
229
+ Status.UNKNOWN: ("UNKNOWN", "magenta"),
230
+ Status.PASS: ("PASS", "green"),
231
+ Status.NOT_APPLICABLE: ("N/A", "dim"),
232
+ Status.NOT_TESTED: ("NOT TESTED", "dim"),
233
+ }
234
+ _PROBLEMS = frozenset({Status.FAIL, Status.WARN, Status.UNKNOWN})
235
+
236
+
237
+ def _render_findings(snapshot: Snapshot, console: Console) -> None:
238
+ """Findings per subject (host, then each interpreter) in evaluation order.
239
+
240
+ Problems show their reason, the next step and how to check it; consequences of a
241
+ root cause are dimmed and say which finding they follow from.
242
+ """
243
+ paths = {interpreter.label: interpreter.path for interpreter in snapshot.interpreters}
244
+ subjects = ["host", *paths, "comparison"]
245
+ console.print()
246
+ console.print(Text("Findings", style="bold"))
247
+ for subject in subjects:
248
+ group = [f for f in snapshot.findings if f.subject == subject]
249
+ if not group:
250
+ continue
251
+ heading = (
252
+ "Host"
253
+ if subject == "host"
254
+ else "Comparison"
255
+ if subject == "comparison"
256
+ else f"{subject} {paths[subject]}"
257
+ )
258
+ console.print(Text(heading, style="bold"))
259
+ table = Table.grid(padding=(0, 1))
260
+ table.add_column(width=10, no_wrap=True)
261
+ table.add_column(width=13, no_wrap=True) # GSI-TORCH-002 is the longest code
262
+ # Fold, never crop: people copy the commands and paths shown here.
263
+ table.add_column(overflow="fold")
264
+ for finding in group:
265
+ _add_finding(table, finding)
266
+ console.print(table)
267
+ console.print(Text(_tally(snapshot.findings)))
268
+
269
+
270
+ def _add_finding(table: Table, finding: Finding) -> None:
271
+ label, style = _LABELS[finding.status]
272
+ summary = finding.summary
273
+ if finding.caused_by:
274
+ summary += f" (follows from {finding.caused_by})"
275
+ style = "dim"
276
+ table.add_row(Text(label, style=style), Text(finding.id, style="dim"), Text(summary))
277
+ if finding.status not in _PROBLEMS:
278
+ return
279
+ details = [finding.reason]
280
+ if finding.recommended_action:
281
+ details.append(f"Next: {finding.recommended_action}")
282
+ if finding.verify_command:
283
+ details.append(f"Check: {finding.verify_command}")
284
+ details.extend(f"Note: {limitation}" for limitation in finding.limitations)
285
+ for detail in details:
286
+ table.add_row(Text(""), Text(""), Text(detail))
287
+
288
+
289
+ def _tally(findings: tuple[Finding, ...]) -> str:
290
+ counts = Counter(f.status for f in findings)
291
+ parts = [
292
+ f"{counts[Status.FAIL]} failed",
293
+ f"{counts[Status.WARN]} warning{'' if counts[Status.WARN] == 1 else 's'}",
294
+ f"{counts[Status.UNKNOWN]} unknown",
295
+ f"{counts[Status.PASS]} passed",
296
+ f"{counts[Status.NOT_APPLICABLE] + counts[Status.NOT_TESTED]} not applicable or not tested",
297
+ ]
298
+ return "Summary: " + " · ".join(parts)
@@ -0,0 +1 @@
1
+ """Pure inference: functions of collected facts. Nothing here runs commands or reads files."""
@@ -0,0 +1,21 @@
1
+ """The built-in rules. Modules register into RULES when imported, in evaluation order."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+
7
+ from gpu_stack_inspector.rules.engine import Registry
8
+
9
+ RULES = Registry()
10
+
11
+ _MODULES = ("host", "interpreters", "compatibility", "differences")
12
+ """Evaluation order: the machine first, then each interpreter, then how they fit together,
13
+ then how the interpreters differ from each other.
14
+ Kept as data on purpose: import sorting would otherwise reorder the rules."""
15
+
16
+
17
+ def builtin_rules() -> Registry:
18
+ """All built-in rules, in evaluation order."""
19
+ for name in _MODULES:
20
+ importlib.import_module(f"gpu_stack_inspector.rules.{name}")
21
+ return RULES
@@ -0,0 +1,73 @@
1
+ """Field-by-field comparison of inspected interpreters. Pure: facts in, rows out."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+
7
+ from gpu_stack_inspector.models import FieldComparison, InterpreterFacts
8
+ from gpu_stack_inspector.rules.torch_build import TorchBuildKind, classify_torch
9
+
10
+
11
+ def _flag(value: bool | None) -> str | None:
12
+ return None if value is None else ("yes" if value else "no")
13
+
14
+
15
+ _INTERPRETER_FIELDS: tuple[tuple[str, Callable[[InterpreterFacts], str | None]], ...] = (
16
+ ("Python", lambda i: i.version),
17
+ ("implementation", lambda i: i.implementation),
18
+ ("conda env", lambda i: _flag(i.conda)),
19
+ ("venv", lambda i: _flag(i.virtualenv)),
20
+ ("platform", lambda i: i.platform),
21
+ ("user site", lambda i: _flag(i.user_site)),
22
+ ("PYTHONPATH set", lambda i: _flag(i.pythonpath_set)),
23
+ )
24
+
25
+
26
+ def torch_build_label(interpreter: InterpreterFacts) -> str | None:
27
+ """The build in plain words ("CUDA 12.8", "CPU only", "not installed"…)."""
28
+ if interpreter.version is None:
29
+ return None
30
+ build = classify_torch(interpreter.torch)
31
+ if build.kind is TorchBuildKind.CUDA:
32
+ return f"CUDA {build.cuda_version}" if build.cuda_version else "CUDA"
33
+ return {
34
+ TorchBuildKind.CPU: "CPU only",
35
+ TorchBuildKind.ROCM: "ROCm",
36
+ TorchBuildKind.UNKNOWN: "unknown",
37
+ TorchBuildKind.MISSING: "not installed",
38
+ }[build.kind]
39
+
40
+
41
+ _TORCH_FIELDS: tuple[tuple[str, Callable[[InterpreterFacts], str | None]], ...] = (
42
+ ("torch", lambda i: i.torch.version if i.torch else None),
43
+ ("torch build", torch_build_label),
44
+ ("torch location", lambda i: i.torch.location if i.torch else None),
45
+ )
46
+
47
+
48
+ def _package_version(interpreter: InterpreterFacts, name: str) -> str | None:
49
+ """The installed version; several installs of one package (shadowing) are all named."""
50
+ versions = [d.version for d in interpreter.distributions if d.name == name]
51
+ if not versions:
52
+ return None
53
+ if len(versions) == 1:
54
+ return versions[0]
55
+ return f"{', '.join(sorted(set(versions)))} ({len(versions)} installs)"
56
+
57
+
58
+ def compare_interpreters(interpreters: Sequence[InterpreterFacts]) -> tuple[FieldComparison, ...]:
59
+ """Rows for every compared field, in a fixed order; ``differs`` marks disagreement.
60
+
61
+ After the interpreter and torch rows comes one row per GPU-relevant package found in
62
+ any of them, alphabetically; ``None`` there means "not installed".
63
+ """
64
+ fixed = tuple(
65
+ FieldComparison(name, tuple(value(i) for i in interpreters))
66
+ for name, value in (*_INTERPRETER_FIELDS, *_TORCH_FIELDS)
67
+ )
68
+ names = sorted({d.name for i in interpreters for d in i.distributions} - {"torch"})
69
+ packages = tuple(
70
+ FieldComparison(name, tuple(_package_version(i, name) for i in interpreters))
71
+ for name in names
72
+ )
73
+ return fixed + packages