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,160 @@
1
+ """CUDA toolkits on disk and the nvcc on PATH."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import shutil
9
+ from collections.abc import Callable, Mapping, Sequence
10
+ from pathlib import Path
11
+
12
+ from gpu_stack_inspector.models import CudaToolkit, FailureKind, ProbeFailure, ToolkitFacts
13
+ from gpu_stack_inspector.runner import CommandRunner, probe_failure
14
+
15
+ TIMEOUT = 8.0
16
+ DEFAULT_ROOTS: tuple[Path, ...] = () if os.name == "nt" else (Path("/usr/local"), Path("/opt"))
17
+ """Where toolkits are installed: /usr/local/cuda-12.4 (NVIDIA's installer), /opt/cuda (Arch)."""
18
+
19
+ _MAX_FILE_BYTES = 64 * 1024
20
+ _PROBE = "cuda-toolkits"
21
+ _ENV_DIRS = ("CUDA_HOME", "CUDA_PATH", "CUDA_ROOT")
22
+ """Variables naming a toolkit; Windows also sets one CUDA_PATH_V<major>_<minor> per version."""
23
+ _TOOLKIT_DIR = re.compile(r"cuda(?:-[0-9]+(?:\.[0-9]+)*)?")
24
+ _VERSION_TXT = re.compile(r"CUDA Version\s+(?P<version>[0-9]+(?:\.[0-9]+)+)")
25
+ _CUDA_H = re.compile(r"^\s*#\s*define\s+CUDA_VERSION\s+(?P<number>[0-9]{4,6})\b", re.MULTILINE)
26
+ _ENV = {"LC_ALL": "C"}
27
+ _NVCC_RELEASE = re.compile(
28
+ r"release\s+(?P<release>[0-9]+(?:\.[0-9]+)*),\s*V(?P<full>[0-9]+(?:\.[0-9]+)*)"
29
+ )
30
+
31
+
32
+ def collect_toolkits(
33
+ runner: CommandRunner,
34
+ *,
35
+ which: Callable[[str], str | None] = shutil.which,
36
+ roots: Sequence[Path] = DEFAULT_ROOTS,
37
+ environ: Mapping[str, str] = os.environ,
38
+ timeout: float = TIMEOUT,
39
+ ) -> ToolkitFacts:
40
+ """Find CUDA toolkit directories and the nvcc on PATH.
41
+
42
+ Directories are only listed and small version files read; the only command run is
43
+ ``nvcc --version``.
44
+ """
45
+ toolkits, dir_failures = find_toolkits(roots, environ)
46
+ nvcc_facts = _collect_nvcc(runner, which, timeout)
47
+ return ToolkitFacts(
48
+ nvcc_path=nvcc_facts.nvcc_path,
49
+ nvcc_version=nvcc_facts.nvcc_version,
50
+ toolkits=tuple(toolkits),
51
+ failures=(*dir_failures, *nvcc_facts.failures),
52
+ )
53
+
54
+
55
+ def find_toolkits(
56
+ roots: Sequence[Path], environ: Mapping[str, str] | None = None
57
+ ) -> tuple[list[CudaToolkit], list[ProbeFailure]]:
58
+ """Toolkit directories under each root and named by CUDA_HOME/CUDA_PATH/….
59
+
60
+ Each directory is reported once under its real path; symlinks to it, such as
61
+ ``/usr/local/cuda``, are listed in ``linked_from``. A variable that names a missing
62
+ directory is a failure worth reporting: tools that read it will not find CUDA.
63
+ """
64
+ candidates: list[Path] = []
65
+ for root in roots:
66
+ try:
67
+ entries = sorted(root.iterdir())
68
+ except OSError: # a missing or unreadable root is normal
69
+ continue
70
+ candidates.extend(entry for entry in entries if _TOOLKIT_DIR.fullmatch(entry.name))
71
+
72
+ failures: list[ProbeFailure] = []
73
+ env = environ or {}
74
+ names = [*_ENV_DIRS, *sorted(n for n in env if n.startswith("CUDA_PATH_V"))]
75
+ for name in names:
76
+ value = env.get(name, "")
77
+ if not value:
78
+ continue
79
+ if Path(value).is_dir():
80
+ candidates.append(Path(value))
81
+ else:
82
+ failures.append(
83
+ ProbeFailure(
84
+ _PROBE, FailureKind.NOT_FOUND, f"{name} names {value}, which is not a directory"
85
+ )
86
+ )
87
+
88
+ aliases: dict[Path, list[str]] = {}
89
+ for candidate in candidates:
90
+ try:
91
+ target = candidate.resolve(strict=True)
92
+ except OSError: # dangling symlink
93
+ continue
94
+ if not target.is_dir():
95
+ continue
96
+ links = aliases.setdefault(target, [])
97
+ if candidate.is_symlink() and str(candidate) not in links:
98
+ links.append(str(candidate))
99
+
100
+ toolkits = []
101
+ for target, links in aliases.items():
102
+ version, source = read_toolkit_version(target)
103
+ toolkits.append(CudaToolkit(str(target), version, source, tuple(links)))
104
+ return toolkits, failures
105
+
106
+
107
+ def read_toolkit_version(directory: Path) -> tuple[str | None, str | None]:
108
+ """The toolkit's version and the file it came from, or ``(None, None)``."""
109
+ text = _read_small(directory / "version.json")
110
+ if text is not None:
111
+ try:
112
+ version = json.loads(text)["cuda"]["version"]
113
+ except (ValueError, KeyError, TypeError):
114
+ version = None
115
+ if isinstance(version, str) and version:
116
+ return version, "version.json"
117
+ text = _read_small(directory / "version.txt") # CUDA 10.x and older
118
+ match = _VERSION_TXT.search(text) if text is not None else None
119
+ if match:
120
+ return match["version"], "version.txt"
121
+ text = _read_small(directory / "include" / "cuda.h")
122
+ match = _CUDA_H.search(text) if text is not None else None
123
+ if match:
124
+ # CUDA_VERSION is 1000 * major + 10 * minor: 12040 is 12.4.
125
+ number = int(match["number"])
126
+ return f"{number // 1000}.{number % 1000 // 10}", "include/cuda.h"
127
+ return None, None
128
+
129
+
130
+ def _read_small(path: Path) -> str | None:
131
+ try:
132
+ with path.open("rb") as handle:
133
+ return handle.read(_MAX_FILE_BYTES).decode("utf-8", errors="replace")
134
+ except OSError:
135
+ return None
136
+
137
+
138
+ def _collect_nvcc(
139
+ runner: CommandRunner, which: Callable[[str], str | None], timeout: float
140
+ ) -> ToolkitFacts:
141
+ nvcc = which("nvcc")
142
+ if nvcc is None:
143
+ return ToolkitFacts()
144
+ result = runner.run([nvcc, "--version"], timeout=timeout, env_overrides=_ENV)
145
+ failure = probe_failure("nvcc", result)
146
+ if failure is not None:
147
+ return ToolkitFacts(nvcc_path=nvcc, failures=(failure,))
148
+ version = parse_nvcc_version(result.stdout)
149
+ failures: tuple[ProbeFailure, ...] = ()
150
+ if version is None:
151
+ failures = (
152
+ ProbeFailure("nvcc", FailureKind.MALFORMED_OUTPUT, "nvcc --version printed no release"),
153
+ )
154
+ return ToolkitFacts(nvcc_path=nvcc, nvcc_version=version, failures=failures)
155
+
156
+
157
+ def parse_nvcc_version(stdout: str) -> str | None:
158
+ """``Cuda compilation tools, release 12.4, V12.4.131`` → ``12.4.131``."""
159
+ match = _NVCC_RELEASE.search(stdout)
160
+ return match["full"] if match else None
@@ -0,0 +1 @@
1
+ """Versioned, source-tagged compatibility data and its loader."""
@@ -0,0 +1,24 @@
1
+ {
2
+ "schema": 1,
3
+ "retrieved": "2026-11-03",
4
+ "sources": {
5
+ "nvidia-cuda-compatibility": {
6
+ "title": "NVIDIA: CUDA Compatibility",
7
+ "url": "https://docs.nvidia.com/deploy/cuda-compatibility/latest/"
8
+ },
9
+ "nvidia-minor-version-compatibility": {
10
+ "title": "NVIDIA: CUDA Compatibility, Minor Version Compatibility",
11
+ "url": "https://docs.nvidia.com/deploy/cuda-compatibility/minor-version-compatibility.html"
12
+ }
13
+ },
14
+ "minor_version_compatibility": {
15
+ "from_cuda_major": 11,
16
+ "source": "nvidia-minor-version-compatibility",
17
+ "note": "Applications built within a CUDA major release family run on a driver of that family's minimum or newer, with a limited feature set; code compiled to PTX does not work on older drivers."
18
+ },
19
+ "minimum_driver": [
20
+ {"cuda_major": 11, "driver": "450", "source": "nvidia-minor-version-compatibility"},
21
+ {"cuda_major": 12, "driver": "525", "source": "nvidia-minor-version-compatibility"},
22
+ {"cuda_major": 13, "driver": "580", "source": "nvidia-minor-version-compatibility"}
23
+ ]
24
+ }
@@ -0,0 +1,75 @@
1
+ """Loads data.json and refuses data that is not tied to a source."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import re
8
+ from dataclasses import dataclass
9
+ from importlib.resources import files
10
+ from typing import Any
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class Source:
15
+ id: str
16
+ title: str
17
+ url: str
18
+
19
+
20
+ @dataclass(frozen=True, slots=True)
21
+ class CompatibilityData:
22
+ """CUDA compatibility facts, each tied to the document it was taken from."""
23
+
24
+ retrieved: str
25
+ sources: dict[str, Source]
26
+ minor_version_from_major: int
27
+ minor_version_source: Source
28
+ minor_version_note: str
29
+ minimum_driver: dict[int, tuple[str, Source]]
30
+ """CUDA major version → (minimum driver version, source)."""
31
+
32
+
33
+ class CompatibilityDataError(ValueError):
34
+ pass
35
+
36
+
37
+ @functools.cache
38
+ def load() -> CompatibilityData:
39
+ raw = files("gpu_stack_inspector.compatibility").joinpath("data.json").read_text("utf-8")
40
+ return parse(json.loads(raw))
41
+
42
+
43
+ def parse(data: dict[str, Any]) -> CompatibilityData:
44
+ if data.get("schema") != 1:
45
+ raise CompatibilityDataError(f"unsupported schema {data.get('schema')!r}")
46
+ retrieved = data.get("retrieved", "")
47
+ if not re.fullmatch(r"\d{4}-\d{2}-\d{2}", str(retrieved)):
48
+ raise CompatibilityDataError("retrieved must be a YYYY-MM-DD date")
49
+ sources = {
50
+ key: Source(key, value["title"], value["url"]) for key, value in data["sources"].items()
51
+ }
52
+ for source in sources.values():
53
+ if not source.url.startswith("https://"):
54
+ raise CompatibilityDataError(f"source {source.id} has no https URL")
55
+
56
+ def source_of(entry: dict[str, Any]) -> Source:
57
+ key = entry.get("source")
58
+ if key not in sources:
59
+ raise CompatibilityDataError(f"unknown source {key!r} in {entry}")
60
+ return sources[key]
61
+
62
+ minor = data["minor_version_compatibility"]
63
+ minimum: dict[int, tuple[str, Source]] = {}
64
+ for row in data["minimum_driver"]:
65
+ if not re.fullmatch(r"\d+(\.\d+)*", str(row["driver"])):
66
+ raise CompatibilityDataError(f"driver version {row['driver']!r} is not a version")
67
+ minimum[int(row["cuda_major"])] = (str(row["driver"]), source_of(row))
68
+ return CompatibilityData(
69
+ retrieved=retrieved,
70
+ sources=sources,
71
+ minor_version_from_major=int(minor["from_cuda_major"]),
72
+ minor_version_source=source_of(minor),
73
+ minor_version_note=str(minor["note"]),
74
+ minimum_driver=minimum,
75
+ )
@@ -0,0 +1,25 @@
1
+ """Comparing dotted version numbers as reported by drivers, nvcc and wheels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _LEADING_VERSION = re.compile(r"[0-9]+(?:\.[0-9]+)*")
8
+
9
+
10
+ def version_tuple(text: str | None) -> tuple[int, ...] | None:
11
+ """``12.8`` → ``(12, 8)``, ``595.99.02`` → ``(595, 99, 2)``; no leading number → None."""
12
+ if not text:
13
+ return None
14
+ match = _LEADING_VERSION.match(text.strip())
15
+ if match is None:
16
+ return None
17
+ return tuple(int(part) for part in match.group(0).split("."))
18
+
19
+
20
+ def major_minor(text: str | None) -> tuple[int, int] | None:
21
+ """The CUDA-relevant part of a version: ``12.4.131`` → ``(12, 4)``, ``13`` → ``(13, 0)``."""
22
+ parts = version_tuple(text)
23
+ if parts is None:
24
+ return None
25
+ return parts[0], parts[1] if len(parts) > 1 else 0
@@ -0,0 +1,22 @@
1
+ """Exit codes are a contract: scripts and CI jobs depend on them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from enum import IntEnum
6
+
7
+
8
+ class ExitCode(IntEnum):
9
+ OK = 0
10
+ """The inspection ran. Findings, even failing ones, do not change this."""
11
+
12
+ STRICT_FAILURE = 1
13
+ """``--strict`` was given and a finding reached the configured fail level."""
14
+
15
+ ERROR = 2
16
+ """Wrong usage, or the tool itself failed."""
17
+
18
+ CONTEXT_UNAVAILABLE = 3
19
+ """Something the command needs is missing, e.g. an interpreter that was named."""
20
+
21
+ INTERRUPTED = 130
22
+ """Stopped with Ctrl+C (128 + SIGINT, as shells report it)."""
@@ -0,0 +1,349 @@
1
+ """Typed facts and findings shared by collectors, rules and renderers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import UTC, datetime
8
+ from enum import StrEnum
9
+
10
+ SCHEMA_VERSION = 1
11
+ """Version of the snapshot/JSON report layout; bumped only for incompatible changes."""
12
+
13
+ FINDING_ID = re.compile(r"GSI-[A-Z]+-[0-9]{3}")
14
+ """Finding codes are part of the public contract: ``GSI-<AREA>-<NNN>``, e.g. ``GSI-TORCH-002``."""
15
+
16
+
17
+ class Status(StrEnum):
18
+ """Outcome of one check.
19
+
20
+ Only ``PASS`` means that a check ran and succeeded. A check that could not run is
21
+ ``UNKNOWN``; one that was deliberately skipped (an opt-in probe nobody asked for) is
22
+ ``NOT_TESTED``; one that does not apply to this machine is ``NOT_APPLICABLE``.
23
+ """
24
+
25
+ PASS = "pass" # noqa: S105 - a status name, not a password
26
+ WARN = "warn"
27
+ FAIL = "fail"
28
+ UNKNOWN = "unknown"
29
+ NOT_APPLICABLE = "not_applicable"
30
+ NOT_TESTED = "not_tested"
31
+
32
+
33
+ class Severity(StrEnum):
34
+ """How much a finding matters to the user's goal, independent of certainty."""
35
+
36
+ INFO = "info"
37
+ LOW = "low"
38
+ MEDIUM = "medium"
39
+ HIGH = "high"
40
+
41
+
42
+ class Confidence(StrEnum):
43
+ """How well the evidence supports a finding."""
44
+
45
+ LOW = "low"
46
+ MEDIUM = "medium"
47
+ HIGH = "high"
48
+
49
+
50
+ @dataclass(frozen=True, slots=True)
51
+ class Evidence:
52
+ """One observed fact that a finding relies on.
53
+
54
+ ``source`` names where the fact came from (``nvidia-smi``, ``python-metadata``…),
55
+ ``key`` what it is (``driver_version``…). ``value`` is ``None`` when the fact is
56
+ missing, which is itself evidence.
57
+ """
58
+
59
+ source: str
60
+ key: str
61
+ value: str | None
62
+
63
+
64
+ @dataclass(frozen=True, slots=True)
65
+ class Finding:
66
+ """A diagnosis with everything a user needs to judge and verify it.
67
+
68
+ ``subject`` says what the finding is about: ``host`` or the label of an inspected
69
+ environment. ``verify_command`` is a command the user can run to check the result
70
+ after acting on ``recommended_action``. ``caused_by`` names the finding this one
71
+ follows from (``GSI-SYS-001`` for "driver not checked because nvidia-smi is missing"),
72
+ so reports can show one root cause instead of a cascade.
73
+ """
74
+
75
+ id: str
76
+ status: Status
77
+ severity: Severity
78
+ confidence: Confidence
79
+ summary: str
80
+ reason: str
81
+ subject: str = "host"
82
+ evidence: tuple[Evidence, ...] = ()
83
+ recommended_action: str | None = None
84
+ verify_command: str | None = None
85
+ limitations: tuple[str, ...] = ()
86
+ caused_by: str | None = None
87
+
88
+ def __post_init__(self) -> None:
89
+ if not FINDING_ID.fullmatch(self.id):
90
+ raise ValueError(f"finding id {self.id!r} does not match GSI-<AREA>-<NNN>")
91
+ if self.caused_by is not None and not FINDING_ID.fullmatch(self.caused_by):
92
+ raise ValueError(f"caused_by {self.caused_by!r} does not match GSI-<AREA>-<NNN>")
93
+ if not self.summary.strip():
94
+ raise ValueError(f"finding {self.id} has an empty summary")
95
+
96
+
97
+ class FailureKind(StrEnum):
98
+ """Why a probe produced no usable facts."""
99
+
100
+ NOT_FOUND = "not_found"
101
+ PERMISSION_DENIED = "permission_denied"
102
+ TIMEOUT = "timeout"
103
+ NONZERO_EXIT = "nonzero_exit"
104
+ MALFORMED_OUTPUT = "malformed_output"
105
+ OS_ERROR = "os_error"
106
+ PROBE_ERROR = "probe_error"
107
+ """The probe ran, but reported that part of its own work failed."""
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class ProbeFailure:
112
+ """A probe that could not deliver its facts.
113
+
114
+ Collectors return these instead of raising, so one broken tool never hides the
115
+ results of the others, and rules can report ``unknown`` with the reason.
116
+ """
117
+
118
+ probe: str
119
+ kind: FailureKind
120
+ detail: str
121
+
122
+
123
+ def utc_timestamp() -> str:
124
+ """Current time as an ISO 8601 UTC string with second precision."""
125
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
126
+
127
+
128
+ @dataclass(frozen=True, slots=True)
129
+ class RedactionCount:
130
+ """How many values of one kind (``home_path``, ``secret_value``…) were redacted."""
131
+
132
+ kind: str
133
+ count: int
134
+
135
+
136
+ @dataclass(frozen=True, slots=True)
137
+ class Snapshot:
138
+ """Everything one run observed and concluded; the input of every renderer.
139
+
140
+ ``probe_failures`` collects the failures of every collector in one place, so a report
141
+ can list what could not be checked.
142
+ """
143
+
144
+ tool_version: str
145
+ created_at: str
146
+ schema_version: int = SCHEMA_VERSION
147
+ host: HostFacts | None = None
148
+ nvidia: NvidiaFacts | None = None
149
+ toolkit: ToolkitFacts | None = None
150
+ cuda_environment: tuple[EnvironmentVariable, ...] = ()
151
+ interpreters: tuple[InterpreterFacts, ...] = ()
152
+ comparison: tuple[FieldComparison, ...] = ()
153
+ """Field-by-field differences between the interpreters, when they were compared."""
154
+ probe_failures: tuple[ProbeFailure, ...] = ()
155
+ findings: tuple[Finding, ...] = ()
156
+ redactions: tuple[RedactionCount, ...] = ()
157
+ """What redaction removed before this snapshot was output; empty if nothing was."""
158
+
159
+
160
+ @dataclass(frozen=True, slots=True)
161
+ class HostFacts:
162
+ """The operating system the inspection ran on."""
163
+
164
+ system: str
165
+ """``platform.system()``: ``Linux``, ``Windows``, ``Darwin``…"""
166
+ machine: str
167
+ """CPU architecture as the OS reports it: ``x86_64``, ``AMD64``, ``aarch64``…"""
168
+ kernel: str | None
169
+ distribution: str | None = None
170
+ """``PRETTY_NAME`` from os-release on Linux, e.g. ``Ubuntu 26.04.1 LTS``."""
171
+ wsl: bool = False
172
+ """Running inside Windows Subsystem for Linux (a fact only; WSL is not validated yet)."""
173
+
174
+
175
+ @dataclass(frozen=True, slots=True)
176
+ class GpuDevice:
177
+ """One GPU as nvidia-smi lists it. Values it reports as N/A are ``None``."""
178
+
179
+ index: int
180
+ name: str
181
+ memory_total_mib: int | None = None
182
+ compute_capability: str | None = None
183
+ """``major.minor``, e.g. ``8.6``; decides which CUDA kernels a wheel can run."""
184
+
185
+
186
+ @dataclass(frozen=True, slots=True)
187
+ class NvidiaFacts:
188
+ """What nvidia-smi reported.
189
+
190
+ ``smi_path`` is ``None`` when nvidia-smi is not installed (or not on ``PATH``), which
191
+ is a fact, not a failure. ``failures`` say why other fields are missing.
192
+ """
193
+
194
+ smi_path: str | None
195
+ driver_version: str | None = None
196
+ max_cuda_version: str | None = None
197
+ """The newest CUDA version the driver supports (nvidia-smi's "CUDA Version").
198
+
199
+ It is not the version of any installed CUDA toolkit or of PyTorch's CUDA runtime.
200
+ """
201
+ gpus: tuple[GpuDevice, ...] = ()
202
+ failures: tuple[ProbeFailure, ...] = ()
203
+
204
+
205
+ @dataclass(frozen=True, slots=True)
206
+ class CudaToolkit:
207
+ """A CUDA toolkit directory found on disk.
208
+
209
+ ``linked_from`` lists symlinks that point at it, e.g. ``/usr/local/cuda`` for the
210
+ default toolkit. ``version_source`` names the file the version was read from.
211
+ """
212
+
213
+ path: str
214
+ version: str | None = None
215
+ version_source: str | None = None
216
+ linked_from: tuple[str, ...] = ()
217
+
218
+
219
+ @dataclass(frozen=True, slots=True)
220
+ class ToolkitFacts:
221
+ """Locally installed CUDA toolkits and the ``nvcc`` on ``PATH``.
222
+
223
+ Prebuilt PyTorch wheels bring their own CUDA runtime, so a missing toolkit is not a
224
+ problem by itself; it matters for compiling extensions.
225
+ """
226
+
227
+ nvcc_path: str | None = None
228
+ nvcc_version: str | None = None
229
+ toolkits: tuple[CudaToolkit, ...] = ()
230
+ failures: tuple[ProbeFailure, ...] = ()
231
+
232
+
233
+ @dataclass(frozen=True, slots=True)
234
+ class EnvironmentVariable:
235
+ """An allowlisted variable that was set. An empty value is kept: for
236
+ ``CUDA_VISIBLE_DEVICES`` it means "no GPU is visible"."""
237
+
238
+ name: str
239
+ value: str
240
+
241
+
242
+ @dataclass(frozen=True, slots=True)
243
+ class Distribution:
244
+ """An installed distribution relevant to GPU use, as the target interpreter sees it.
245
+
246
+ ``location`` is the directory it was found in; the same name in two locations (a
247
+ user site and the environment, say) is how shadowed installs show up.
248
+ """
249
+
250
+ name: str
251
+ version: str
252
+ location: str | None = None
253
+
254
+
255
+ @dataclass(frozen=True, slots=True)
256
+ class TorchFacts:
257
+ """Static evidence about the torch an interpreter would import. Nothing was imported.
258
+
259
+ ``built_cuda``/``built_hip`` come from torch/version.py; they are only meaningful when
260
+ ``version_py_found`` is true, since ``None`` there means "built without CUDA".
261
+ ``conda_records`` is ``None`` outside conda environments.
262
+ """
263
+
264
+ version: str
265
+ location: str | None = None
266
+ requires_nvidia: tuple[str, ...] = ()
267
+ cuda_library: bool | None = None
268
+ hip_library: bool | None = None
269
+ version_py_found: bool = False
270
+ built_cuda: str | None = None
271
+ built_hip: str | None = None
272
+ conda_records: tuple[str, ...] | None = None
273
+
274
+
275
+ @dataclass(frozen=True, slots=True)
276
+ class RuntimeStep:
277
+ """One step of the runtime probe: import torch, cuda available, devices, tensor operation."""
278
+
279
+ step: str
280
+ ok: bool
281
+ seconds: float | None = None
282
+ error: str | None = None
283
+
284
+
285
+ @dataclass(frozen=True, slots=True)
286
+ class RuntimeFacts:
287
+ """What happened when torch was imported and CUDA used (only with --runtime).
288
+
289
+ ``failure`` is set when the interpreter itself crashed, hung or printed no result; the
290
+ steps then end with the one it was in.
291
+ """
292
+
293
+ torch_version: str | None = None
294
+ cuda_version: str | None = None
295
+ """torch.version.cuda at runtime: the CUDA version the build targets."""
296
+ cuda_available: bool | None = None
297
+ devices: tuple[GpuDevice, ...] = ()
298
+ tensor_ok: bool | None = None
299
+ steps: tuple[RuntimeStep, ...] = ()
300
+ failure: ProbeFailure | None = None
301
+
302
+
303
+ @dataclass(frozen=True, slots=True)
304
+ class InterpreterFacts:
305
+ """What one target Python interpreter reported about itself.
306
+
307
+ ``path`` is the interpreter as the user named it, made absolute but with symlinks
308
+ kept: a virtual environment's ``bin/python`` is a symlink, and following it would
309
+ start the base interpreter outside the environment.
310
+ """
311
+
312
+ label: str
313
+ path: str
314
+ executable: str | None = None
315
+ version: str | None = None
316
+ implementation: str | None = None
317
+ prefix: str | None = None
318
+ base_prefix: str | None = None
319
+ conda: bool | None = None
320
+ platform: str | None = None
321
+ user_site: bool | None = None
322
+ pythonpath_set: bool | None = None
323
+ distributions: tuple[Distribution, ...] = ()
324
+ torch: TorchFacts | None = None
325
+ runtime: RuntimeFacts | None = None
326
+ """None unless the runtime probe was run (``probe --runtime``)."""
327
+ failures: tuple[ProbeFailure, ...] = ()
328
+
329
+ @property
330
+ def virtualenv(self) -> bool | None:
331
+ """Whether the interpreter runs inside a venv/virtualenv, if that is known."""
332
+ if self.prefix is None or self.base_prefix is None:
333
+ return None
334
+ return self.prefix != self.base_prefix
335
+
336
+
337
+ @dataclass(frozen=True, slots=True)
338
+ class FieldComparison:
339
+ """One field across the compared interpreters, in their order.
340
+
341
+ ``None`` means the interpreter has no value for it (not installed, not inspected).
342
+ """
343
+
344
+ field: str
345
+ values: tuple[str | None, ...]
346
+
347
+ @property
348
+ def differs(self) -> bool:
349
+ return len(set(self.values)) > 1