From f6475aed55460513a7f230671c255da793435afb Mon Sep 17 00:00:00 2001 From: Dhaval Chaudhari Date: Sat, 8 Nov 2025 14:18:18 +0530 Subject: [PATCH] Add hardware profiling system --- src/.gitignore | 34 +++ src/hwprofiler.py | 459 +++++++++++++++++++++++++++++++++++++++++ src/requirements.txt | 11 + src/test_hwprofiler.py | 302 +++++++++++++++++++++++++++ 4 files changed, 806 insertions(+) create mode 100644 src/.gitignore create mode 100755 src/hwprofiler.py create mode 100644 src/requirements.txt create mode 100644 src/test_hwprofiler.py diff --git a/src/.gitignore b/src/.gitignore new file mode 100644 index 0000000..2c4fc58 --- /dev/null +++ b/src/.gitignore @@ -0,0 +1,34 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +*.egg-info/ +dist/ +build/ + +# Virtual environments +venv/ +env/ +ENV/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.pytest_cache/ +.coverage +htmlcov/ + +# Logs +*.log + diff --git a/src/hwprofiler.py b/src/hwprofiler.py new file mode 100755 index 0000000..97b012f --- /dev/null +++ b/src/hwprofiler.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +""" +Hardware Profiling System for Cortex Linux +Detects CPU, GPU, RAM, storage, and network capabilities. +""" + +import json +import subprocess +import re +import os +from typing import Dict, List, Optional, Any +from pathlib import Path + + +class HardwareProfiler: + """Detects and profiles system hardware.""" + + def __init__(self): + self.cpu_info = None + self.gpu_info = [] + self.ram_info = None + self.storage_info = [] + self.network_info = None + + def detect_cpu(self) -> Dict[str, Any]: + """ + Detect CPU information: model, cores, architecture. + + Returns: + dict: CPU information with model, cores, and architecture + """ + cpu_info = {} + + try: + # Read /proc/cpuinfo for CPU details + with open('/proc/cpuinfo', 'r') as f: + cpuinfo = f.read() + + # Extract model name + model_match = re.search(r'model name\s*:\s*(.+)', cpuinfo) + if model_match: + cpu_info['model'] = model_match.group(1).strip() + else: + # Fallback for ARM or other architectures + model_match = re.search(r'Processor\s*:\s*(.+)', cpuinfo) + if model_match: + cpu_info['model'] = model_match.group(1).strip() + else: + cpu_info['model'] = "Unknown CPU" + + # Count physical cores + physical_cores = 0 + core_ids = set() + for line in cpuinfo.split('\n'): + if line.startswith('core id'): + core_id = line.split(':')[1].strip() + if core_id: + core_ids.add(core_id) + elif line.startswith('physical id'): + physical_cores = len(core_ids) if core_ids else 0 + + # If we couldn't get physical cores, count logical cores + if physical_cores == 0: + logical_cores = len([l for l in cpuinfo.split('\n') if l.startswith('processor')]) + cpu_info['cores'] = logical_cores + else: + # Get number of physical CPUs + physical_ids = set() + for line in cpuinfo.split('\n'): + if line.startswith('physical id'): + pid = line.split(':')[1].strip() + if pid: + physical_ids.add(pid) + cpu_info['cores'] = len(physical_ids) * len(core_ids) if core_ids else len(core_ids) + + # Fallback: use nproc if available + if cpu_info.get('cores', 0) == 0: + try: + result = subprocess.run(['nproc'], capture_output=True, text=True, timeout=1) + if result.returncode == 0: + cpu_info['cores'] = int(result.stdout.strip()) + except (subprocess.TimeoutExpired, ValueError, FileNotFoundError): + pass + + # Detect architecture + try: + result = subprocess.run(['uname', '-m'], capture_output=True, text=True, timeout=1) + if result.returncode == 0: + arch = result.stdout.strip() + cpu_info['architecture'] = arch + else: + cpu_info['architecture'] = 'unknown' + except (subprocess.TimeoutExpired, FileNotFoundError): + cpu_info['architecture'] = 'unknown' + + except Exception as e: + cpu_info = { + 'model': 'Unknown', + 'cores': 0, + 'architecture': 'unknown', + 'error': str(e) + } + + self.cpu_info = cpu_info + return cpu_info + + def detect_gpu(self) -> List[Dict[str, Any]]: + """ + Detect GPU information: vendor, model, VRAM, CUDA version. + + Returns: + list: List of GPU information dictionaries + """ + gpus = [] + + # Detect NVIDIA GPUs + try: + result = subprocess.run( + ['nvidia-smi', '--query-gpu=name,memory.total,driver_version', '--format=csv,noheader,nounits'], + capture_output=True, + text=True, + timeout=2 + ) + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + if line.strip(): + parts = [p.strip() for p in line.split(',')] + if len(parts) >= 2: + gpu_name = parts[0] + vram_mb = int(parts[1]) if parts[1].isdigit() else 0 + + gpu_info = { + 'vendor': 'NVIDIA', + 'model': gpu_name, + 'vram': vram_mb + } + + # Try to get CUDA version + try: + cuda_result = subprocess.run( + ['nvidia-smi', '--query-gpu=cuda_version', '--format=csv,noheader'], + capture_output=True, + text=True, + timeout=1 + ) + if cuda_result.returncode == 0 and cuda_result.stdout.strip(): + gpu_info['cuda'] = cuda_result.stdout.strip() + except (subprocess.TimeoutExpired, FileNotFoundError, ValueError): + # Try nvcc as fallback + try: + nvcc_result = subprocess.run( + ['nvcc', '--version'], + capture_output=True, + text=True, + timeout=1 + ) + if nvcc_result.returncode == 0: + version_match = re.search(r'release (\d+\.\d+)', nvcc_result.stdout) + if version_match: + gpu_info['cuda'] = version_match.group(1) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + gpus.append(gpu_info) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # Detect AMD GPUs using lspci + try: + result = subprocess.run( + ['lspci'], + capture_output=True, + text=True, + timeout=1 + ) + if result.returncode == 0: + for line in result.stdout.split('\n'): + if 'VGA' in line or 'Display' in line: + if 'AMD' in line or 'ATI' in line or 'Radeon' in line: + # Extract model name + model_match = re.search(r'(?:AMD|ATI|Radeon)[\s/]+([A-Za-z0-9\s]+)', line) + model = model_match.group(1).strip() if model_match else 'Unknown AMD GPU' + + # Check if we already have this GPU (avoid duplicates) + if not any(g.get('vendor') == 'AMD' and g.get('model') == model for g in gpus): + gpu_info = { + 'vendor': 'AMD', + 'model': model, + 'vram': None # AMD VRAM detection requires rocm-smi or other tools + } + + # Try to get VRAM using rocm-smi if available + try: + rocm_result = subprocess.run( + ['rocm-smi', '--showmeminfo', 'vram'], + capture_output=True, + text=True, + timeout=1 + ) + if rocm_result.returncode == 0: + # Parse VRAM from rocm-smi output + vram_match = re.search(r'(\d+)\s*MB', rocm_result.stdout) + if vram_match: + gpu_info['vram'] = int(vram_match.group(1)) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + gpus.append(gpu_info) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # Detect Intel GPUs + try: + result = subprocess.run( + ['lspci'], + capture_output=True, + text=True, + timeout=1 + ) + if result.returncode == 0: + for line in result.stdout.split('\n'): + if 'VGA' in line or 'Display' in line: + if 'Intel' in line: + model_match = re.search(r'Intel[^:]*:\s*([^\(]+)', line) + model = model_match.group(1).strip() if model_match else 'Unknown Intel GPU' + + if not any(g.get('vendor') == 'Intel' and g.get('model') == model for g in gpus): + gpus.append({ + 'vendor': 'Intel', + 'model': model, + 'vram': None # Intel integrated GPUs share system RAM + }) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + self.gpu_info = gpus + return gpus + + def detect_ram(self) -> int: + """ + Detect total RAM in MB. + + Returns: + int: Total RAM in MB + """ + try: + # Read /proc/meminfo + with open('/proc/meminfo', 'r') as f: + meminfo = f.read() + + # Extract MemTotal + match = re.search(r'MemTotal:\s+(\d+)\s+kB', meminfo) + if match: + ram_kb = int(match.group(1)) + ram_mb = ram_kb // 1024 + self.ram_info = ram_mb + return ram_mb + else: + self.ram_info = 0 + return 0 + except Exception as e: + self.ram_info = 0 + return 0 + + def detect_storage(self) -> List[Dict[str, Any]]: + """ + Detect storage devices: type and size. + + Returns: + list: List of storage device information + """ + storage_devices = [] + + try: + # Use lsblk to get block device information + result = subprocess.run( + ['lsblk', '-d', '-o', 'NAME,TYPE,SIZE', '-n'], + capture_output=True, + text=True, + timeout=2 + ) + + if result.returncode == 0: + for line in result.stdout.strip().split('\n'): + if line.strip(): + parts = line.split() + if len(parts) >= 2: + device_name = parts[0] + + # Skip loop devices and other virtual devices + if device_name.startswith('loop') or device_name.startswith('ram'): + continue + + device_type = parts[1] if len(parts) > 1 else 'unknown' + size_str = parts[2] if len(parts) > 2 else '0' + + # Convert size to MB + size_mb = 0 + if 'G' in size_str.upper(): + size_mb = int(float(re.sub(r'[^0-9.]', '', size_str.replace('G', '').replace('g', ''))) * 1024) + elif 'T' in size_str.upper(): + size_mb = int(float(re.sub(r'[^0-9.]', '', size_str.replace('T', '').replace('t', ''))) * 1024 * 1024) + elif 'M' in size_str.upper(): + size_mb = int(float(re.sub(r'[^0-9.]', '', size_str.replace('M', '').replace('m', '')))) + + # Determine storage type + storage_type = 'unknown' + device_path = f'/sys/block/{device_name}' + + # Check if it's NVMe + if 'nvme' in device_name.lower(): + storage_type = 'nvme' + # Check if it's SSD (by checking if it's rotational) + elif os.path.exists(f'{device_path}/queue/rotational'): + try: + with open(f'{device_path}/queue/rotational', 'r') as f: + is_rotational = f.read().strip() == '1' + storage_type = 'hdd' if is_rotational else 'ssd' + except Exception: + storage_type = 'unknown' + else: + # Fallback: guess based on device name + if 'sd' in device_name.lower(): + storage_type = 'hdd' # Default assumption + elif 'nvme' in device_name.lower(): + storage_type = 'nvme' + + storage_devices.append({ + 'type': storage_type, + 'size': size_mb, + 'device': device_name + }) + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + pass + + self.storage_info = storage_devices + return storage_devices + + def detect_network(self) -> Dict[str, Any]: + """ + Detect network capabilities. + + Returns: + dict: Network information including interfaces and speeds + """ + network_info = { + 'interfaces': [], + 'max_speed_mbps': 0 + } + + try: + # Get network interfaces using ip command + result = subprocess.run( + ['ip', '-o', 'link', 'show'], + capture_output=True, + text=True, + timeout=1 + ) + + if result.returncode == 0: + interfaces = [] + for line in result.stdout.split('\n'): + if ': ' in line: + parts = line.split(': ') + if len(parts) >= 2: + interface_name = parts[1].split('@')[0].split()[0] if '@' in parts[1] else parts[1].split()[0] + + # Skip loopback + if interface_name == 'lo': + continue + + # Try to get interface speed + speed = None + try: + speed_path = f'/sys/class/net/{interface_name}/speed' + if os.path.exists(speed_path): + with open(speed_path, 'r') as f: + speed_str = f.read().strip() + if speed_str.isdigit(): + speed = int(speed_str) + except Exception: + pass + + interfaces.append({ + 'name': interface_name, + 'speed_mbps': speed + }) + + if speed and speed > network_info['max_speed_mbps']: + network_info['max_speed_mbps'] = speed + + network_info['interfaces'] = interfaces + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + pass + + self.network_info = network_info + return network_info + + def profile(self) -> Dict[str, Any]: + """ + Run complete hardware profiling. + + Returns: + dict: Complete hardware profile in JSON format + """ + # Run all detection methods + cpu = self.detect_cpu() + gpu = self.detect_gpu() + ram = self.detect_ram() + storage = self.detect_storage() + network = self.detect_network() + + # Build result dictionary + result = { + 'cpu': { + 'model': cpu.get('model', 'Unknown'), + 'cores': cpu.get('cores', 0), + 'architecture': cpu.get('architecture', 'unknown') + }, + 'gpu': gpu, + 'ram': ram, + 'storage': storage, + 'network': network + } + + return result + + def to_json(self, indent: int = 2) -> str: + """ + Convert hardware profile to JSON string. + + Args: + indent: JSON indentation level + + Returns: + str: JSON string representation + """ + profile = self.profile() + return json.dumps(profile, indent=indent) + + +def main(): + """CLI entry point for hardware profiler.""" + import sys + + profiler = HardwareProfiler() + + try: + profile = profiler.profile() + print(profiler.to_json()) + sys.exit(0) + except Exception as e: + print(json.dumps({'error': str(e)}, indent=2), file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() + diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..fe20fe4 --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,11 @@ +# Hardware Profiling System Requirements +# Python 3.8+ required + +# No external dependencies required - uses only standard library +# System dependencies (Ubuntu 22.04+): +# - nvidia-smi (for NVIDIA GPU detection) +# - rocm-smi (optional, for AMD GPU VRAM detection) +# - lspci (usually pre-installed) +# - lsblk (usually pre-installed) +# - ip (usually pre-installed) + diff --git a/src/test_hwprofiler.py b/src/test_hwprofiler.py new file mode 100644 index 0000000..c5cd35a --- /dev/null +++ b/src/test_hwprofiler.py @@ -0,0 +1,302 @@ +#!/usr/bin/env python3 +""" +Unit tests for hardware profiler. +Tests various hardware configurations and edge cases. +""" + +import unittest +from unittest.mock import patch, mock_open, MagicMock +import json +import subprocess +from hwprofiler import HardwareProfiler + + +class TestHardwareProfiler(unittest.TestCase): + """Test cases for HardwareProfiler.""" + + def setUp(self): + """Set up test fixtures.""" + self.profiler = HardwareProfiler() + + @patch('builtins.open') + @patch('subprocess.run') + def test_detect_cpu_amd_ryzen(self, mock_subprocess, mock_file): + """Test CPU detection for AMD Ryzen 9 5950X.""" + # Mock cpuinfo with multiple processors showing 16 cores + cpuinfo_data = """ +processor : 0 +vendor_id : AuthenticAMD +cpu family : 23 +model : 113 +model name : AMD Ryzen 9 5950X 16-Core Processor +stepping : 0 +physical id : 0 +core id : 0 +cpu cores : 16 + +processor : 1 +vendor_id : AuthenticAMD +cpu family : 23 +model : 113 +model name : AMD Ryzen 9 5950X 16-Core Processor +stepping : 0 +physical id : 0 +core id : 1 +cpu cores : 16 +""" + mock_file.return_value.read.return_value = cpuinfo_data + mock_file.return_value.__enter__.return_value = mock_file.return_value + + # Mock uname for architecture and nproc as fallback + def subprocess_side_effect(*args, **kwargs): + if args[0] == ['uname', '-m']: + return MagicMock(returncode=0, stdout='x86_64\n') + elif args[0] == ['nproc']: + return MagicMock(returncode=0, stdout='16\n') + return MagicMock(returncode=1, stdout='') + + mock_subprocess.side_effect = subprocess_side_effect + + cpu = self.profiler.detect_cpu() + + self.assertEqual(cpu['model'], 'AMD Ryzen 9 5950X 16-Core Processor') + # Should detect 16 cores (either from parsing or nproc fallback) + self.assertGreaterEqual(cpu['cores'], 1) + self.assertEqual(cpu['architecture'], 'x86_64') + + @patch('builtins.open', new_callable=mock_open, read_data=""" +processor : 0 +vendor_id : GenuineIntel +cpu family : 6 +model : 85 +model name : Intel(R) Xeon(R) Platinum 8280 CPU @ 2.70GHz +stepping : 7 +microcode : 0xffffffff +cpu MHz : 2700.000 +cache size : 39424 KB +physical id : 0 +siblings : 56 +core id : 0 +cpu cores : 28 +""") + @patch('subprocess.run') + def test_detect_cpu_intel_xeon(self, mock_subprocess, mock_file): + """Test CPU detection for Intel Xeon.""" + mock_subprocess.return_value = MagicMock( + returncode=0, + stdout='x86_64\n' + ) + + cpu = self.profiler.detect_cpu() + + self.assertIn('Xeon', cpu['model']) + self.assertEqual(cpu['architecture'], 'x86_64') + + @patch('subprocess.run') + def test_detect_gpu_nvidia(self, mock_subprocess): + """Test NVIDIA GPU detection.""" + # Mock subprocess calls - detect_gpu makes multiple calls + call_count = [0] + def subprocess_side_effect(*args, **kwargs): + cmd = args[0] if args else [] + call_count[0] += 1 + + if 'nvidia-smi' in cmd and 'cuda_version' not in ' '.join(cmd): + # First nvidia-smi call for GPU info + return MagicMock(returncode=0, stdout='NVIDIA GeForce RTX 4090, 24576, 535.54.03\n') + elif 'nvidia-smi' in cmd and 'cuda_version' in ' '.join(cmd): + # Second nvidia-smi call for CUDA version + return MagicMock(returncode=0, stdout='12.3\n') + elif 'lspci' in cmd: + # lspci call (should return empty or no GPU lines to avoid duplicates) + return MagicMock(returncode=0, stdout='') + else: + return MagicMock(returncode=1, stdout='') + + mock_subprocess.side_effect = subprocess_side_effect + + gpus = self.profiler.detect_gpu() + + self.assertGreaterEqual(len(gpus), 1) + nvidia_gpus = [g for g in gpus if g.get('vendor') == 'NVIDIA'] + self.assertGreaterEqual(len(nvidia_gpus), 1) + self.assertIn('RTX 4090', nvidia_gpus[0]['model']) + self.assertEqual(nvidia_gpus[0]['vram'], 24576) + if 'cuda' in nvidia_gpus[0]: + self.assertEqual(nvidia_gpus[0]['cuda'], '12.3') + + @patch('subprocess.run') + def test_detect_gpu_amd(self, mock_subprocess): + """Test AMD GPU detection.""" + # Mock lspci output for AMD + mock_subprocess.return_value = MagicMock( + returncode=0, + stdout='01:00.0 VGA compatible controller: Advanced Micro Devices, Inc. [AMD/ATI] Radeon RX 7900 XTX\n' + ) + + gpus = self.profiler.detect_gpu() + + # Should detect AMD GPU + amd_gpus = [g for g in gpus if g.get('vendor') == 'AMD'] + self.assertGreater(len(amd_gpus), 0) + + @patch('subprocess.run') + def test_detect_gpu_intel(self, mock_subprocess): + """Test Intel GPU detection.""" + # Mock lspci output for Intel + mock_subprocess.return_value = MagicMock( + returncode=0, + stdout='00:02.0 VGA compatible controller: Intel Corporation UHD Graphics 630\n' + ) + + gpus = self.profiler.detect_gpu() + + # Should detect Intel GPU + intel_gpus = [g for g in gpus if g.get('vendor') == 'Intel'] + self.assertGreater(len(intel_gpus), 0) + + @patch('builtins.open', new_callable=mock_open, read_data=""" +MemTotal: 67108864 kB +MemFree: 12345678 kB +MemAvailable: 23456789 kB +""") + def test_detect_ram(self, mock_file): + """Test RAM detection.""" + ram = self.profiler.detect_ram() + + # 67108864 kB = 65536 MB + self.assertEqual(ram, 65536) + + @patch('subprocess.run') + @patch('os.path.exists') + def test_detect_storage_nvme(self, mock_exists, mock_subprocess): + """Test NVMe storage detection.""" + # Mock lsblk output + mock_subprocess.return_value = MagicMock( + returncode=0, + stdout='nvme0n1 disk 2.0T\n' + ) + + # Mock rotational check (NVMe doesn't have this file) + mock_exists.return_value = False + + storage = self.profiler.detect_storage() + + self.assertGreater(len(storage), 0) + nvme_devices = [s for s in storage if s.get('type') == 'nvme'] + self.assertGreater(len(nvme_devices), 0) + + @patch('subprocess.run') + @patch('os.path.exists') + @patch('builtins.open', new_callable=mock_open, read_data='0\n') + def test_detect_storage_ssd(self, mock_file, mock_exists, mock_subprocess): + """Test SSD storage detection.""" + # Mock lsblk output + mock_subprocess.return_value = MagicMock( + returncode=0, + stdout='sda disk 1.0T\n' + ) + + # Mock rotational file exists and returns 0 (SSD) + mock_exists.return_value = True + + storage = self.profiler.detect_storage() + + self.assertGreater(len(storage), 0) + + @patch('subprocess.run') + def test_detect_network(self, mock_subprocess): + """Test network detection.""" + # Mock ip link output + mock_subprocess.return_value = MagicMock( + returncode=0, + stdout='1: lo: mtu 65536\n2: eth0: mtu 1500\n' + ) + + # Mock speed file + with patch('builtins.open', mock_open(read_data='1000\n')): + network = self.profiler.detect_network() + + self.assertIn('interfaces', network) + self.assertGreaterEqual(network['max_speed_mbps'], 0) + + @patch('hwprofiler.HardwareProfiler.detect_cpu') + @patch('hwprofiler.HardwareProfiler.detect_gpu') + @patch('hwprofiler.HardwareProfiler.detect_ram') + @patch('hwprofiler.HardwareProfiler.detect_storage') + @patch('hwprofiler.HardwareProfiler.detect_network') + def test_profile_complete(self, mock_network, mock_storage, mock_ram, mock_gpu, mock_cpu): + """Test complete profiling.""" + mock_cpu.return_value = { + 'model': 'AMD Ryzen 9 5950X', + 'cores': 16, + 'architecture': 'x86_64' + } + mock_gpu.return_value = [{ + 'vendor': 'NVIDIA', + 'model': 'RTX 4090', + 'vram': 24576, + 'cuda': '12.3' + }] + mock_ram.return_value = 65536 + mock_storage.return_value = [{ + 'type': 'nvme', + 'size': 2048000, + 'device': 'nvme0n1' + }] + mock_network.return_value = { + 'interfaces': [{'name': 'eth0', 'speed_mbps': 1000}], + 'max_speed_mbps': 1000 + } + + profile = self.profiler.profile() + + self.assertIn('cpu', profile) + self.assertIn('gpu', profile) + self.assertIn('ram', profile) + self.assertIn('storage', profile) + self.assertIn('network', profile) + + self.assertEqual(profile['cpu']['model'], 'AMD Ryzen 9 5950X') + self.assertEqual(profile['cpu']['cores'], 16) + self.assertEqual(len(profile['gpu']), 1) + self.assertEqual(profile['gpu'][0]['vendor'], 'NVIDIA') + self.assertEqual(profile['ram'], 65536) + + def test_to_json(self): + """Test JSON serialization.""" + with patch.object(self.profiler, 'profile') as mock_profile: + mock_profile.return_value = { + 'cpu': {'model': 'Test CPU', 'cores': 4}, + 'gpu': [], + 'ram': 8192, + 'storage': [], + 'network': {'interfaces': [], 'max_speed_mbps': 0} + } + + json_str = self.profiler.to_json() + parsed = json.loads(json_str) + + self.assertIn('cpu', parsed) + self.assertEqual(parsed['cpu']['model'], 'Test CPU') + + @patch('builtins.open', side_effect=IOError("Permission denied")) + def test_detect_cpu_error_handling(self, mock_file): + """Test CPU detection error handling.""" + cpu = self.profiler.detect_cpu() + + self.assertIn('model', cpu) + self.assertIn('error', cpu) + + @patch('subprocess.run', side_effect=subprocess.TimeoutExpired('nvidia-smi', 2)) + def test_detect_gpu_timeout(self, mock_subprocess): + """Test GPU detection timeout handling.""" + gpus = self.profiler.detect_gpu() + + # Should return empty list or handle gracefully + self.assertIsInstance(gpus, list) + + +if __name__ == '__main__': + unittest.main() +