"""v0.44.0 Part A — `soup monitor` GPU live-monitor primitives. Pure-Python helpers for parsing nvidia-smi CSV output and Apple Silicon `powermetrics` output. Subprocess invocations use list args (no shell). """ from __future__ import annotations import shutil import subprocess # noqa: S404 — list-args invocation only from dataclasses import dataclass from typing import List, Optional, Tuple # Bounds (defence-in-depth) _NVIDIA_SMI_TIMEOUT_S = 5 _MAX_GPUS = 128 @dataclass(frozen=True) class GpuSample: """One row of nvidia-smi output for a single GPU.""" index: int name: str util_gpu_pct: Optional[float] util_mem_pct: Optional[float] mem_used_mb: Optional[float] mem_total_mb: Optional[float] temp_c: Optional[float] power_w: Optional[float] def _parse_float_or_none(text: str) -> Optional[float]: cleaned = text.strip() if not cleaned or cleaned in {"[N/A]", "N/A", "[Not Supported]"}: return None # nvidia-smi suffixes units in some configs; keep numeric prefix only. head = cleaned.split()[0] try: return float(head) except (ValueError, TypeError): return None def parse_nvidia_smi_csv(text: str) -> List[GpuSample]: """Parse `nvidia-smi --query-gpu=... --format=csv,noheader` output. Expected query order: index,name,utilization.gpu,utilization.memory,memory.used,memory.total,temperature.gpu,power.draw Lines that don't have exactly 8 columns are skipped silently. """ if not isinstance(text, str): raise TypeError("text must be str") samples: List[GpuSample] = [] for line in text.splitlines(): if not line.strip(): continue cols = [col.strip() for col in line.split(",")] if len(cols) != 8: continue try: index = int(cols[0]) except (ValueError, TypeError): continue if index < 0 or index >= _MAX_GPUS: continue # Reject embedded NUL byte in the GPU name (defence-in-depth). name = cols[1] if "\x00" in name: continue samples.append( GpuSample( index=index, name=name, util_gpu_pct=_parse_float_or_none(cols[2]), util_mem_pct=_parse_float_or_none(cols[3]), mem_used_mb=_parse_float_or_none(cols[4]), mem_total_mb=_parse_float_or_none(cols[5]), temp_c=_parse_float_or_none(cols[6]), power_w=_parse_float_or_none(cols[7]), ) ) return samples def query_nvidia_smi() -> Tuple[bool, List[GpuSample]]: """Invoke nvidia-smi and return (ok, samples). ok=False when smi is missing or returns a non-zero exit. Never raises.""" smi_path = shutil.which("nvidia-smi") if smi_path is None: return False, [] argv = [ smi_path, "--query-gpu=index,name,utilization.gpu,utilization.memory," "memory.used,memory.total,temperature.gpu,power.draw", "--format=csv,noheader,nounits", ] try: result = subprocess.run( # noqa: S603 — list args, no shell argv, capture_output=True, text=True, timeout=_NVIDIA_SMI_TIMEOUT_S, check=False, ) except (OSError, subprocess.TimeoutExpired): return False, [] if result.returncode != 0: return False, [] return True, parse_nvidia_smi_csv(result.stdout or "") def detect_apple_silicon() -> bool: """Best-effort detection of Apple Silicon hardware (Mac M-series). Uses `platform.system()` + `platform.machine()` — the conditional logic here is intentionally simple to avoid the prior version's parser-priority bug where `if X if Y else Z:` produced a load-bearing-coincidence on every platform. """ try: import platform except ImportError: return False if platform.system() != "Darwin": return False return platform.machine().lower() in {"arm64", "aarch64"}