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,232 @@
1
+ """The metadata probe: run inside a *target* interpreter as ``python -c <this file>``.
2
+
3
+ Rules for this file, checked by tests:
4
+
5
+ - Python 3.8 syntax and the standard library only; targets can be much older than the
6
+ tool itself.
7
+ - Nothing from the target environment is imported. Package metadata is read from disk.
8
+ - Exactly one JSON document is printed, between BEGIN and END, so whatever a target's
9
+ sitecustomize or .pth files print cannot be mistaken for the result.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import site
18
+ import sys
19
+ import sysconfig
20
+ from typing import Callable
21
+
22
+ PROTOCOL = 1
23
+ BEGIN = "GSI-PROBE-BEGIN"
24
+ END = "GSI-PROBE-END"
25
+
26
+ # Distributions that decide whether and how a Python environment uses an NVIDIA GPU.
27
+ # Only these are reported: a full package list is noise, and can reveal private projects.
28
+ EXACT = frozenset(
29
+ [
30
+ "torch", "torchvision", "torchaudio", "triton", "pytorch-triton", "pytorch-triton-rocm",
31
+ "xformers", "flash-attn", "bitsandbytes", "deepspeed", "vllm", "accelerate",
32
+ "jax", "jaxlib", "tensorflow", "tensorflow-cpu", "tensorflow-gpu", "tf-nightly", "keras",
33
+ "cupy", "onnxruntime", "onnxruntime-gpu", "tensorrt", "numpy",
34
+ ]
35
+ ) # fmt: skip
36
+ PREFIXES = ("nvidia-", "cupy-cuda", "jax-cuda", "tensorrt-")
37
+ MAX_METADATA_BYTES = 64 * 1024
38
+
39
+ # Libraries in torch/lib that only GPU builds ship (Linux, Windows, macOS names).
40
+ CUDA_LIBRARIES = ("libtorch_cuda.so", "torch_cuda.dll", "libtorch_cuda.dylib")
41
+ HIP_LIBRARIES = ("libtorch_hip.so", "torch_hip.dll")
42
+
43
+ # torch/version.py lines, old and new style: "cuda = '11.3'", "cuda: Optional[str] = '12.8'",
44
+ # "hip: Optional[str] = None". The file is read as text, never imported.
45
+ VERSION_PY_LINE = re.compile(
46
+ r"^(?P<key>cuda|hip)\s*(?::[^=\n]*)?=\s*(?:'(?P<single>[^'\n]*)'|\"(?P<double>[^\"\n]*)\"|None)\s*$",
47
+ re.MULTILINE,
48
+ )
49
+
50
+
51
+ def python_facts() -> dict[str, object]:
52
+ enable_user_site = site.ENABLE_USER_SITE
53
+ return {
54
+ "version": "%d.%d.%d" % tuple(sys.version_info[:3]),
55
+ "implementation": sys.implementation.name,
56
+ "executable": sys.executable,
57
+ "prefix": sys.prefix,
58
+ "base_prefix": getattr(sys, "base_prefix", sys.prefix),
59
+ "conda": os.path.isdir(os.path.join(sys.prefix, "conda-meta")),
60
+ "platform": sysconfig.get_platform(),
61
+ "user_site": None if enable_user_site is None else bool(enable_user_site),
62
+ "pythonpath_set": bool(os.environ.get("PYTHONPATH")),
63
+ }
64
+
65
+
66
+ def normalize(name: str) -> str:
67
+ """PEP 503 normalisation: ``Nvidia_CuBLAS.cu12`` → ``nvidia-cublas-cu12``."""
68
+ return re.sub(r"[-_.]+", "-", name).lower()
69
+
70
+
71
+ def wanted(name: str) -> bool:
72
+ normalized = normalize(name)
73
+ return normalized in EXACT or normalized.startswith(PREFIXES)
74
+
75
+
76
+ def distributions() -> list[dict[str, str]]:
77
+ """Allowlisted distributions on sys.path, read from metadata without importing them."""
78
+ try:
79
+ from importlib import metadata
80
+ except ImportError: # Python before 3.8
81
+ return scan_dist_info(sys.path)
82
+
83
+ found = []
84
+ for dist in metadata.distributions():
85
+ name = dist.metadata["Name"] if dist.metadata is not None else None
86
+ version = dist.version
87
+ if not name or not version or not wanted(name):
88
+ continue
89
+ found.append(
90
+ {"name": normalize(name), "version": version, "location": str(dist.locate_file(""))}
91
+ )
92
+ return sorted(found, key=lambda d: (d["name"], d["location"]))
93
+
94
+
95
+ def torch_facts() -> dict[str, object] | None:
96
+ """Static signals about the torch that ``import torch`` would find, without importing it.
97
+
98
+ Returns None when torch is not installed.
99
+ """
100
+ try:
101
+ from importlib import metadata
102
+ except ImportError: # Python before 3.8: the distributions section still lists torch
103
+ return None
104
+ try:
105
+ dist = metadata.distribution("torch")
106
+ except metadata.PackageNotFoundError:
107
+ return None
108
+ requirements = []
109
+ for requirement in dist.requires or []:
110
+ match = re.match(r"[A-Za-z0-9._-]+", requirement)
111
+ if match and normalize(match.group(0)).startswith("nvidia-"):
112
+ requirements.append(normalize(match.group(0)))
113
+ package = str(dist.locate_file("torch"))
114
+ return {
115
+ "version": dist.version,
116
+ "location": str(dist.locate_file("")),
117
+ "requires_nvidia": sorted(set(requirements)),
118
+ "libraries": torch_libraries(os.path.join(package, "lib")),
119
+ "version_py": torch_version_py(os.path.join(package, "version.py")),
120
+ "conda_records": conda_records(sys.prefix),
121
+ }
122
+
123
+
124
+ def conda_records(prefix: str) -> list[str] | None:
125
+ """Conda packages that decide the torch build, by file name in conda-meta.
126
+
127
+ Names like ``pytorch-1.12.0-py3.8_cuda11.3_cudnn8.3.2_0`` carry the build string, so
128
+ the (multi-megabyte) records are never opened. None outside conda environments.
129
+ """
130
+ try:
131
+ names = os.listdir(os.path.join(prefix, "conda-meta"))
132
+ except OSError:
133
+ return None
134
+ wanted_prefixes = ("pytorch-", "cudatoolkit-", "cuda-version-", "cuda-runtime-")
135
+ return sorted(
136
+ name[: -len(".json")]
137
+ for name in names
138
+ if name.endswith(".json") and name.startswith(wanted_prefixes)
139
+ )
140
+
141
+
142
+ def torch_version_py(path: str) -> dict[str, str | None] | None:
143
+ """The cuda and hip versions torch was built with, read from torch/version.py as text.
144
+
145
+ None when the file is missing or names neither (a damaged or unusual install).
146
+ """
147
+ try:
148
+ with open(path, "rb") as handle:
149
+ text = handle.read(MAX_METADATA_BYTES).decode("utf-8", "replace")
150
+ except OSError:
151
+ return None
152
+ found: dict[str, str | None] = {}
153
+ for match in VERSION_PY_LINE.finditer(text):
154
+ value = match.group("single")
155
+ if value is None:
156
+ value = match.group("double")
157
+ found.setdefault(match.group("key"), value)
158
+ if not found:
159
+ return None
160
+ return {"cuda": found.get("cuda"), "hip": found.get("hip")}
161
+
162
+
163
+ def torch_libraries(lib_dir: str) -> dict[str, bool] | None:
164
+ """Whether torch/lib holds CUDA or ROCm libraries; None if the folder cannot be read."""
165
+ try:
166
+ names = set(os.listdir(lib_dir))
167
+ except OSError:
168
+ return None
169
+ return {
170
+ "cuda": any(name in names for name in CUDA_LIBRARIES),
171
+ "hip": any(name in names for name in HIP_LIBRARIES),
172
+ }
173
+
174
+
175
+ def scan_dist_info(paths: list[str]) -> list[dict[str, str]]:
176
+ """Fallback without importlib.metadata: read the headers of ``*.dist-info/METADATA``."""
177
+ found = []
178
+ for entry in paths:
179
+ try:
180
+ folders = sorted(os.listdir(entry or "."))
181
+ except OSError:
182
+ continue
183
+ for folder in folders:
184
+ if not folder.endswith(".dist-info"):
185
+ continue
186
+ headers = read_headers(os.path.join(entry, folder, "METADATA"))
187
+ name, version = headers.get("name"), headers.get("version")
188
+ if name and version and wanted(name):
189
+ found.append({"name": normalize(name), "version": version, "location": entry})
190
+ return found
191
+
192
+
193
+ def read_headers(path: str) -> dict[str, str]:
194
+ """``Name:``/``Version:`` style headers up to the first blank line."""
195
+ headers: dict[str, str] = {}
196
+ try:
197
+ with open(path, "rb") as handle:
198
+ text = handle.read(MAX_METADATA_BYTES).decode("utf-8", "replace")
199
+ except OSError:
200
+ return headers
201
+ for line in text.splitlines():
202
+ if not line.strip():
203
+ break
204
+ key, separator, value = line.partition(":")
205
+ if separator and key.strip().lower() not in headers:
206
+ headers[key.strip().lower()] = value.strip()
207
+ return headers
208
+
209
+
210
+ def collect(sections: list[tuple[str, Callable[[], object]]]) -> dict[str, object]:
211
+ """Run each section; an exception is reported and the other sections still run."""
212
+ errors: list[str] = []
213
+ result: dict[str, object] = {"protocol": PROTOCOL, "errors": errors}
214
+ for key, function in sections:
215
+ try:
216
+ result[key] = function()
217
+ except Exception as exc: # a partial answer is worth more than none
218
+ result[key] = None
219
+ errors.append("%s: %s: %s" % (key, type(exc).__name__, exc))
220
+ return result
221
+
222
+
223
+ def main() -> None:
224
+ result = collect(
225
+ [("python", python_facts), ("distributions", distributions), ("torch", torch_facts)]
226
+ )
227
+ sys.stdout.write(BEGIN + json.dumps(result, ensure_ascii=True, sort_keys=True) + END + "\n")
228
+ sys.stdout.flush()
229
+
230
+
231
+ if __name__ == "__main__":
232
+ main()
@@ -0,0 +1,153 @@
1
+ """Running the runtime probe in a target interpreter (only with --runtime) and reading it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ import json
7
+ import tempfile
8
+ from importlib.resources import files
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ from gpu_stack_inspector.models import (
13
+ FailureKind,
14
+ GpuDevice,
15
+ ProbeFailure,
16
+ RuntimeFacts,
17
+ RuntimeStep,
18
+ )
19
+ from gpu_stack_inspector.probes.metadata_probe import python_error_line
20
+ from gpu_stack_inspector.runner import CommandResult, CommandRunner, Outcome, probe_failure
21
+
22
+ PROBE = "runtime"
23
+ TIMEOUT = 60.0
24
+ """Importing torch and creating a CUDA context takes a few seconds (2-4 s on the test
25
+ laptop); a driver that hangs must not hang the tool."""
26
+
27
+ BEGIN, END, STEP = "GSI-RUNTIME-BEGIN", "GSI-RUNTIME-END", "GSI-RUNTIME-STEP"
28
+ _SIGNALS = {
29
+ -11: "a segmentation fault (SIGSEGV)",
30
+ 139: "a segmentation fault (SIGSEGV)",
31
+ -6: "an abort (SIGABRT)",
32
+ 134: "an abort (SIGABRT)",
33
+ -9: "being killed (SIGKILL)",
34
+ 137: "being killed (SIGKILL)",
35
+ -7: "a bus error (SIGBUS)",
36
+ 135: "a bus error (SIGBUS)",
37
+ }
38
+
39
+
40
+ @functools.cache
41
+ def program_source() -> str:
42
+ resource = files("gpu_stack_inspector.probes").joinpath("runtime_program.py")
43
+ return resource.read_text(encoding="utf-8")
44
+
45
+
46
+ def run_runtime_probe(
47
+ runner: CommandRunner, path: str, *, timeout: float = TIMEOUT
48
+ ) -> RuntimeFacts:
49
+ """Import torch in the interpreter at ``path`` and use CUDA once. Only with consent."""
50
+ with tempfile.TemporaryDirectory(prefix="gsi-runtime-") as empty:
51
+ result = runner.run([path, "-c", program_source()], timeout=timeout, cwd=Path(empty))
52
+ announced = [
53
+ line[len(STEP) + 1 :].strip()
54
+ for line in result.stdout.splitlines()
55
+ if line.startswith(STEP + " ")
56
+ ]
57
+ if result.outcome is Outcome.TIMEOUT:
58
+ where = f" in step '{announced[-1]}'" if announced else ""
59
+ detail = (
60
+ f"no answer within {result.duration:.0f} s{where}; "
61
+ "CUDA initialisation can hang on a broken driver"
62
+ )
63
+ return _died(announced, ProbeFailure(PROBE, FailureKind.TIMEOUT, detail))
64
+ if result.outcome is Outcome.NONZERO:
65
+ return _died(announced, _crash(result, announced))
66
+ failure = probe_failure(PROBE, result)
67
+ if failure is not None:
68
+ return RuntimeFacts(failure=failure)
69
+ return parse_runtime_output(result.stdout)
70
+
71
+
72
+ def _crash(result: CommandResult, announced: list[str]) -> ProbeFailure:
73
+ signal = _SIGNALS.get(result.returncode or 0)
74
+ how = f"crashed with {signal}" if signal else f"exited with code {result.returncode}"
75
+ where = f" during '{announced[-1]}'" if announced else ""
76
+ reason = python_error_line(result.stderr)
77
+ detail = f"the interpreter {how}{where}" + (f": {reason}" if reason and not signal else "")
78
+ return ProbeFailure(PROBE, FailureKind.NONZERO_EXIT, detail)
79
+
80
+
81
+ def _died(announced: list[str], failure: ProbeFailure) -> RuntimeFacts:
82
+ """Steps reconstructed from the markers: all finished but the last, which it died in."""
83
+ steps = tuple(
84
+ RuntimeStep(
85
+ name,
86
+ ok=index < len(announced) - 1,
87
+ error=None if index < len(announced) - 1 else failure.detail,
88
+ )
89
+ for index, name in enumerate(announced)
90
+ )
91
+ return RuntimeFacts(steps=steps, failure=failure)
92
+
93
+
94
+ def parse_runtime_output(stdout: str) -> RuntimeFacts:
95
+ start = stdout.find(BEGIN)
96
+ end = stdout.find(END, start + len(BEGIN)) if start != -1 else -1
97
+ if start == -1 or end == -1:
98
+ return RuntimeFacts(failure=_malformed("the interpreter printed no runtime result"))
99
+ try:
100
+ document = json.loads(stdout[start + len(BEGIN) : end])
101
+ except ValueError:
102
+ return RuntimeFacts(failure=_malformed("the runtime result is not valid JSON"))
103
+ if not isinstance(document, dict) or document.get("protocol") != 1:
104
+ return RuntimeFacts(failure=_malformed("unexpected runtime probe protocol"))
105
+ steps: list[RuntimeStep] = []
106
+ values: dict[str, Any] = {}
107
+ for raw in document.get("steps") or []:
108
+ if not isinstance(raw, dict) or not isinstance(raw.get("step"), str):
109
+ continue
110
+ seconds = raw.get("seconds")
111
+ error = raw.get("error")
112
+ steps.append(
113
+ RuntimeStep(
114
+ raw["step"],
115
+ ok=raw.get("ok") is True,
116
+ seconds=float(seconds) if isinstance(seconds, int | float) else None,
117
+ error=error if isinstance(error, str) else None,
118
+ )
119
+ )
120
+ if raw.get("ok") is True:
121
+ values[raw["step"]] = raw.get("value")
122
+ imported = values.get("import torch")
123
+ imported = imported if isinstance(imported, dict) else {}
124
+ available = values.get("cuda available")
125
+ attempted = any(s.step == "tensor operation" for s in steps)
126
+ return RuntimeFacts(
127
+ torch_version=_string(imported.get("version")),
128
+ cuda_version=_string(imported.get("cuda")),
129
+ cuda_available=available if isinstance(available, bool) else None,
130
+ devices=_devices(values.get("devices")),
131
+ tensor_ok=("tensor operation" in values) if attempted else None,
132
+ steps=tuple(steps),
133
+ )
134
+
135
+
136
+ def _devices(raw: object) -> tuple[GpuDevice, ...]:
137
+ if not isinstance(raw, list):
138
+ return ()
139
+ return tuple(
140
+ GpuDevice(item["index"], item["name"], None, _string(item.get("capability")))
141
+ for item in raw
142
+ if isinstance(item, dict)
143
+ and isinstance(item.get("index"), int)
144
+ and isinstance(item.get("name"), str)
145
+ )
146
+
147
+
148
+ def _string(value: object) -> str | None:
149
+ return value if isinstance(value, str) else None
150
+
151
+
152
+ def _malformed(detail: str) -> ProbeFailure:
153
+ return ProbeFailure(PROBE, FailureKind.MALFORMED_OUTPUT, detail)
@@ -0,0 +1,101 @@
1
+ """The runtime probe: run inside a *target* interpreter only with --runtime (explicit consent).
2
+
3
+ Unlike the metadata probe, this imports torch, creates a CUDA context and runs a tiny
4
+ operation, because only that proves CUDA works. Rules for this file, checked by tests:
5
+
6
+ - Python 3.8 syntax and the standard library plus torch; nothing else is imported.
7
+ - The workload is tiny (16 floats) and bounded; nothing is written to disk.
8
+ - Each step reports its own failure, so the report can say exactly where CUDA broke.
9
+ - Exactly one JSON document is printed between BEGIN and END.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import sys
16
+ import time
17
+ from typing import Any, Callable
18
+
19
+ PROTOCOL = 1
20
+ BEGIN = "GSI-RUNTIME-BEGIN"
21
+ END = "GSI-RUNTIME-END"
22
+ STEP = "GSI-RUNTIME-STEP"
23
+ """Printed (and flushed) before each step: if CUDA initialisation kills the interpreter,
24
+ no JSON is printed, and the last marker says which step it died in."""
25
+ ELEMENTS = 16
26
+
27
+
28
+ def first_line(exc: BaseException) -> str:
29
+ text = str(exc).strip().splitlines()
30
+ return "%s: %s" % (type(exc).__name__, text[0] if text else "")
31
+
32
+
33
+ def run_steps() -> dict[str, object]:
34
+ result: dict[str, object] = {"protocol": PROTOCOL, "steps": []}
35
+ steps: list[dict[str, object]] = []
36
+ result["steps"] = steps
37
+ state: dict[str, Any] = {}
38
+
39
+ def step(name: str, action: Callable[[], object]) -> bool:
40
+ sys.stdout.write("%s %s\n" % (STEP, name))
41
+ sys.stdout.flush()
42
+ started = time.monotonic()
43
+ try:
44
+ value = action()
45
+ except BaseException as exc: # report where it broke, then stop
46
+ seconds = round(time.monotonic() - started, 3)
47
+ steps.append({"step": name, "ok": False, "error": first_line(exc), "seconds": seconds})
48
+ return False
49
+ seconds = round(time.monotonic() - started, 3)
50
+ steps.append({"step": name, "ok": True, "value": value, "seconds": seconds})
51
+ return True
52
+
53
+ def import_torch() -> object:
54
+ import torch # type: ignore[import-not-found] # lives in the target, never in the tool
55
+
56
+ state["torch"] = torch
57
+ return {"version": torch.__version__, "cuda": torch.version.cuda}
58
+
59
+ def cuda_available() -> object:
60
+ torch = state["torch"]
61
+ return bool(torch.cuda.is_available())
62
+
63
+ def devices() -> object:
64
+ torch = state["torch"]
65
+ count = int(torch.cuda.device_count())
66
+ found = []
67
+ for index in range(count):
68
+ major, minor = torch.cuda.get_device_capability(index)
69
+ name = str(torch.cuda.get_device_name(index))
70
+ found.append({"index": index, "name": name, "capability": "%d.%d" % (major, minor)})
71
+ return found
72
+
73
+ def tensor_operation() -> object:
74
+ torch = state["torch"]
75
+ x = torch.ones(ELEMENTS, device="cuda")
76
+ total = (x * 2).sum()
77
+ torch.cuda.synchronize()
78
+ value = float(total.cpu().item())
79
+ if value != 2.0 * ELEMENTS:
80
+ raise ArithmeticError("expected %s, got %s" % (2.0 * ELEMENTS, value))
81
+ return value
82
+
83
+ if not step("import torch", import_torch):
84
+ return result
85
+ if not step("cuda available", cuda_available):
86
+ return result
87
+ if steps[-1]["value"] is not True:
88
+ return result # nothing more to try: CUDA is not usable from this torch
89
+ if step("devices", devices):
90
+ step("tensor operation", tensor_operation)
91
+ return result
92
+
93
+
94
+ def main() -> None:
95
+ result = run_steps()
96
+ sys.stdout.write(BEGIN + json.dumps(result, ensure_ascii=True, sort_keys=True) + END + "\n")
97
+ sys.stdout.flush()
98
+
99
+
100
+ if __name__ == "__main__":
101
+ main()
@@ -0,0 +1 @@
1
+ """Renderers: they turn an already redacted snapshot into output and never change it."""
@@ -0,0 +1,43 @@
1
+ """JSON output: the snapshot as plain data, for scripts, CI and bug reports."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import fields, is_dataclass
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+ from gpu_stack_inspector.models import FieldComparison, InterpreterFacts, Snapshot
11
+ from gpu_stack_inspector.rules.torch_build import classify_torch
12
+
13
+
14
+ def snapshot_to_dict(snapshot: Snapshot) -> dict[str, Any]:
15
+ """Plain dicts, lists and scalars; enums become their documented string values."""
16
+ plain = _plain(snapshot)
17
+ if not isinstance(plain, dict): # cannot happen: a dataclass becomes a dict
18
+ raise TypeError("snapshot did not serialise to an object")
19
+ return plain
20
+
21
+
22
+ def render_json(snapshot: Snapshot) -> str:
23
+ """Indented JSON with a trailing newline. Pass a redacted snapshot."""
24
+ return json.dumps(snapshot_to_dict(snapshot), indent=2, ensure_ascii=False) + "\n"
25
+
26
+
27
+ def _plain(value: Any) -> Any:
28
+ if isinstance(value, Enum):
29
+ return value.value
30
+ if is_dataclass(value) and not isinstance(value, type):
31
+ result = {f.name: _plain(getattr(value, f.name)) for f in fields(value)}
32
+ if isinstance(value, InterpreterFacts):
33
+ # Derived from the facts above, but what readers and scripts look for.
34
+ result["virtualenv"] = value.virtualenv
35
+ result["torch_build"] = _plain(classify_torch(value.torch))
36
+ if isinstance(value, FieldComparison):
37
+ result["differs"] = value.differs
38
+ return result
39
+ if isinstance(value, tuple | list):
40
+ return [_plain(item) for item in value]
41
+ if isinstance(value, dict):
42
+ return {str(key): _plain(item) for key, item in value.items()}
43
+ return value