Code for How to Get Hardware and System Information in Python Tutorial


View on Github

system_info_report.py

from __future__ import annotations

import argparse
import csv
import json
import os
import platform
import shutil
import socket
import subprocess
import sys
import time
import warnings
from datetime import datetime
from pathlib import Path
from typing import Any

import psutil

try:
    import cpuinfo  # pip install py-cpuinfo
except ImportError:  # py-cpuinfo is optional
    cpuinfo = None


JsonDict = dict[str, Any]


def bytes_to_human(value: int | float | None, suffix: str = "B") -> str:
    """Convert a byte value to a human-readable string."""
    if value is None:
        return "N/A"
    value = float(value)
    for unit in ("", "K", "M", "G", "T", "P"):
        if abs(value) < 1024:
            return f"{value:.2f}{unit}{suffix}"
        value /= 1024
    return f"{value:.2f}EB"


def seconds_to_human(seconds: int | float | None) -> str:
    """Convert seconds to a compact days/hours/minutes string."""
    if seconds is None or seconds < 0:
        return "N/A"
    seconds = int(seconds)
    days, remainder = divmod(seconds, 86_400)
    hours, remainder = divmod(remainder, 3_600)
    minutes, seconds = divmod(remainder, 60)
    parts = []
    if days:
        parts.append(f"{days}d")
    if hours:
        parts.append(f"{hours}h")
    if minutes:
        parts.append(f"{minutes}m")
    if seconds or not parts:
        parts.append(f"{seconds}s")
    return " ".join(parts)


def timestamp_to_iso(timestamp: float) -> str:
    """Return a local ISO timestamp without microseconds."""
    return datetime.fromtimestamp(timestamp).astimezone().isoformat(timespec="seconds")


def cpu_brand() -> str:
    """Return a friendly CPU name when available."""
    if cpuinfo is not None:
        try:
            info = cpuinfo.get_cpu_info()
            brand = info.get("brand_raw") or info.get("arch_string_raw")
            if brand and brand.lower() != "unknown":
                return brand
        except Exception:
            pass
    return platform.processor() or platform.machine() or "Unknown"


def get_linux_distribution() -> JsonDict | None:
    """Return Linux distribution metadata when platform.freedesktop_os_release exists."""
    if platform.system().lower() != "linux":
        return None
    try:
        release = platform.freedesktop_os_release()
    except Exception:
        return None
    return {
        "name": release.get("NAME"),
        "version": release.get("VERSION"),
        "pretty_name": release.get("PRETTY_NAME"),
    }


def collect_system() -> JsonDict:
    """Collect OS, Python, architecture and boot-time information."""
    uname = platform.uname()
    boot_time = psutil.boot_time()
    return {
        "system": uname.system,
        "node": uname.node,
        "release": uname.release,
        "version": uname.version,
        "machine": uname.machine,
        "processor": uname.processor,
        "architecture": platform.architecture()[0],
        "python_version": platform.python_version(),
        "python_executable": sys.executable,
        "boot_time": timestamp_to_iso(boot_time),
        "uptime": seconds_to_human(time.time() - boot_time),
        "linux_distribution": get_linux_distribution(),
    }


def collect_cpu(interval: float = 0.5) -> JsonDict:
    """Collect CPU model, counts, frequency and utilization."""
    freq = psutil.cpu_freq()
    load_avg = None
    if hasattr(os, "getloadavg"):
        try:
            load_avg = os.getloadavg()
        except OSError:
            load_avg = None

    per_core = psutil.cpu_percent(interval=interval, percpu=True)
    return {
        "brand": cpu_brand(),
        "physical_cores": psutil.cpu_count(logical=False),
        "logical_cores": psutil.cpu_count(logical=True),
        "max_frequency_mhz": round(freq.max, 2) if freq else None,
        "min_frequency_mhz": round(freq.min, 2) if freq else None,
        "current_frequency_mhz": round(freq.current, 2) if freq else None,
        "total_usage_percent": psutil.cpu_percent(interval=None),
        "per_core_usage_percent": per_core,
        "load_average": load_avg,
    }


def memory_block(mem: Any) -> JsonDict:
    """Convert a psutil memory namedtuple to raw and human-readable fields."""
    data = mem._asdict()
    return {
        key: value for key, value in data.items()
    } | {
        f"{key}_human": bytes_to_human(value)
        for key, value in data.items()
        if isinstance(value, int) and key != "percent"
    }


def collect_memory() -> JsonDict:
    """Collect virtual memory and swap details."""
    with warnings.catch_warnings():
        warnings.simplefilter("ignore", RuntimeWarning)
        swap = psutil.swap_memory()
    return {
        "virtual": memory_block(psutil.virtual_memory()),
        "swap": memory_block(swap),
    }


def disk_usage_item(device: str, mountpoint: str, fstype: str = "", opts: str = "") -> JsonDict:
    """Build one disk usage dictionary for a mountpoint."""
    item: JsonDict = {"device": device, "mountpoint": mountpoint, "fstype": fstype, "opts": opts}
    try:
        usage = psutil.disk_usage(mountpoint)
    except (PermissionError, FileNotFoundError, OSError) as exc:
        item["error"] = str(exc)
    else:
        item.update(
            {
                "total": usage.total,
                "used": usage.used,
                "free": usage.free,
                "percent": usage.percent,
                "total_human": bytes_to_human(usage.total),
                "used_human": bytes_to_human(usage.used),
                "free_human": bytes_to_human(usage.free),
            }
        )
    return item


def collect_disks(all_partitions: bool = False) -> JsonDict:
    """Collect mounted partitions, disk usage and disk I/O counters."""
    partitions: list[JsonDict] = []
    seen_mountpoints: set[str] = set()

    for partition in psutil.disk_partitions(all=all_partitions):
        if partition.mountpoint in seen_mountpoints:
            continue
        seen_mountpoints.add(partition.mountpoint)
        partitions.append(
            disk_usage_item(
                device=partition.device,
                mountpoint=partition.mountpoint,
                fstype=partition.fstype,
                opts=partition.opts,
            )
        )

    if not partitions:
        partitions.append(disk_usage_item(device="root", mountpoint="/", fstype="unknown"))

    io = psutil.disk_io_counters()
    return {
        "partitions": partitions,
        "io": None
        if io is None
        else {
            **io._asdict(),
            "read_bytes_human": bytes_to_human(io.read_bytes),
            "write_bytes_human": bytes_to_human(io.write_bytes),
        },
    }


def address_family_name(family: socket.AddressFamily | int) -> str:
    """Normalize socket/psutil address family values."""
    if family == socket.AF_INET:
        return "IPv4"
    if family == socket.AF_INET6:
        return "IPv6"
    if hasattr(psutil, "AF_LINK") and family == psutil.AF_LINK:
        return "MAC"
    if hasattr(socket, "AF_PACKET") and family == socket.AF_PACKET:
        return "MAC"
    return str(family)


def collect_network() -> JsonDict:
    """Collect network addresses, interface status and traffic counters."""
    addrs = psutil.net_if_addrs()
    stats = psutil.net_if_stats()
    counters = psutil.net_io_counters(pernic=True)
    interfaces: JsonDict = {}

    for name, addresses in addrs.items():
        interfaces[name] = {
            "is_up": stats[name].isup if name in stats else None,
            "speed_mbps": stats[name].speed if name in stats else None,
            "mtu": stats[name].mtu if name in stats else None,
            "addresses": [
                {
                    "family": address_family_name(address.family),
                    "address": address.address,
                    "netmask": address.netmask,
                    "broadcast": address.broadcast,
                }
                for address in addresses
            ],
            "io": None
            if name not in counters
            else {
                **counters[name]._asdict(),
                "bytes_sent_human": bytes_to_human(counters[name].bytes_sent),
                "bytes_recv_human": bytes_to_human(counters[name].bytes_recv),
            },
        }

    total = psutil.net_io_counters()
    return {
        "interfaces": interfaces,
        "total_io": {
            **total._asdict(),
            "bytes_sent_human": bytes_to_human(total.bytes_sent),
            "bytes_recv_human": bytes_to_human(total.bytes_recv),
        },
    }


def collect_sensors() -> JsonDict:
    """Collect battery, temperature and fan information when the OS exposes it."""
    sensors: JsonDict = {"battery": None, "temperatures": {}, "fans": {}}

    if hasattr(psutil, "sensors_battery"):
        try:
            battery = psutil.sensors_battery()
        except Exception:
            battery = None
        if battery is not None:
            sensors["battery"] = {
                "percent": battery.percent,
                "power_plugged": battery.power_plugged,
                "secs_left": battery.secsleft,
                "time_left": seconds_to_human(battery.secsleft),
            }

    if hasattr(psutil, "sensors_temperatures"):
        try:
            temps = psutil.sensors_temperatures(fahrenheit=False)
            sensors["temperatures"] = {
                name: [entry._asdict() for entry in entries]
                for name, entries in temps.items()
            }
        except Exception:
            pass

    if hasattr(psutil, "sensors_fans"):
        try:
            fans = psutil.sensors_fans()
            sensors["fans"] = {
                name: [entry._asdict() for entry in entries]
                for name, entries in fans.items()
            }
        except Exception:
            pass

    return sensors


def collect_nvidia_gpus() -> list[JsonDict]:
    """Collect NVIDIA GPU information using nvidia-smi when available."""
    if not shutil.which("nvidia-smi"):
        return []

    fields = [
        "index",
        "name",
        "driver_version",
        "memory.total",
        "memory.used",
        "memory.free",
        "utilization.gpu",
        "temperature.gpu",
    ]
    command = [
        "nvidia-smi",
        f"--query-gpu={','.join(fields)}",
        "--format=csv,noheader,nounits",
    ]
    try:
        result = subprocess.run(command, capture_output=True, text=True, check=True, timeout=5)
    except (subprocess.SubprocessError, OSError):
        return []

    gpus: list[JsonDict] = []
    for row in csv.reader(result.stdout.strip().splitlines()):
        if len(row) != len(fields):
            continue
        gpu = {field: value.strip() for field, value in zip(fields, row)}
        for key in ("index", "memory.total", "memory.used", "memory.free", "utilization.gpu", "temperature.gpu"):
            try:
                gpu[key] = int(gpu[key])
            except (TypeError, ValueError):
                pass
        if isinstance(gpu.get("memory.total"), int):
            gpu["memory_total_human"] = bytes_to_human(gpu["memory.total"] * 1024 * 1024)
            gpu["memory_used_human"] = bytes_to_human(gpu["memory.used"] * 1024 * 1024)
            gpu["memory_free_human"] = bytes_to_human(gpu["memory.free"] * 1024 * 1024)
        gpus.append(gpu)
    return gpus


def collect_processes(limit: int = 5, interval: float = 0.2) -> list[JsonDict]:
    """Return top processes by CPU usage over a short sample interval."""
    processes = []
    for proc in psutil.process_iter(["pid", "name", "username", "memory_percent"]):
        try:
            proc.cpu_percent(None)
            processes.append(proc)
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            continue

    time.sleep(interval)
    rows: list[JsonDict] = []
    for proc in processes:
        try:
            info = proc.info
            rows.append(
                {
                    "pid": info.get("pid"),
                    "name": info.get("name"),
                    "username": info.get("username"),
                    "cpu_percent": proc.cpu_percent(None),
                    "memory_percent": round(info.get("memory_percent") or 0, 2),
                }
            )
        except (psutil.NoSuchProcess, psutil.AccessDenied):
            continue

    return sorted(rows, key=lambda item: item["cpu_percent"], reverse=True)[:limit]


def build_report(args: argparse.Namespace) -> JsonDict:
    """Build the complete system report."""
    report: JsonDict = {
        "generated_at": datetime.now().astimezone().isoformat(timespec="seconds"),
        "system": collect_system(),
        "cpu": collect_cpu(interval=args.cpu_interval),
        "memory": collect_memory(),
        "disks": collect_disks(all_partitions=args.all_partitions),
        "network": collect_network(),
        "sensors": collect_sensors(),
        "gpus": {"nvidia": collect_nvidia_gpus()},
    }
    if args.processes:
        report["top_processes"] = collect_processes(limit=args.processes)
    return report


def print_heading(title: str) -> None:
    print(f"\n{'=' * 12} {title} {'=' * 12}")


def print_text_report(report: JsonDict) -> None:
    """Print a compact human-readable report."""
    print_heading("System")
    system = report["system"]
    print(f"OS: {system['system']} {system['release']} ({system['machine']})")
    if system.get("linux_distribution"):
        print(f"Distribution: {system['linux_distribution'].get('pretty_name')}")
    print(f"Node: {system['node']}")
    print(f"Python: {system['python_version']} at {system['python_executable']}")
    print(f"Boot time: {system['boot_time']}  |  Uptime: {system['uptime']}")

    print_heading("CPU")
    cpu = report["cpu"]
    print(f"CPU: {cpu['brand']}")
    print(f"Cores: {cpu['physical_cores']} physical / {cpu['logical_cores']} logical")
    if cpu.get("current_frequency_mhz"):
        print(
            f"Frequency: {cpu['current_frequency_mhz']} MHz "
            f"(min={cpu['min_frequency_mhz']}, max={cpu['max_frequency_mhz']})"
        )
    print(f"Total usage: {cpu['total_usage_percent']}%")
    print("Per-core usage:", ", ".join(f"{value}%" for value in cpu["per_core_usage_percent"]))
    if cpu.get("load_average"):
        print("Load average:", ", ".join(f"{x:.2f}" for x in cpu["load_average"]))

    print_heading("Memory")
    mem = report["memory"]["virtual"]
    swap = report["memory"]["swap"]
    print(f"RAM: {mem['used_human']} used / {mem['total_human']} total ({mem['percent']}%)")
    print(f"Available: {mem['available_human']}")
    print(f"Swap: {swap['used_human']} used / {swap['total_human']} total ({swap['percent']}%)")

    print_heading("Disks")
    for part in report["disks"]["partitions"]:
        if "error" in part:
            print(f"{part['mountpoint']} ({part['fstype']}): {part['error']}")
        else:
            print(
                f"{part['mountpoint']} ({part['fstype']}): "
                f"{part['used_human']} used / {part['total_human']} total ({part['percent']}%)"
            )
    disk_io = report["disks"]["io"]
    if disk_io:
        print(f"Disk I/O since boot: read={disk_io['read_bytes_human']}, write={disk_io['write_bytes_human']}")

    print_heading("Network")
    for name, info in report["network"]["interfaces"].items():
        state = "up" if info["is_up"] else "down"
        addresses = [addr["address"] for addr in info["addresses"] if addr["family"] in {"IPv4", "IPv6", "MAC"}]
        print(f"{name}: {state}, speed={info['speed_mbps']} Mbps, addresses={', '.join(addresses) or 'N/A'}")
    total_io = report["network"]["total_io"]
    print(f"Network I/O since boot: sent={total_io['bytes_sent_human']}, received={total_io['bytes_recv_human']}")

    print_heading("Sensors")
    battery = report["sensors"].get("battery")
    print("Battery:", "N/A" if battery is None else f"{battery['percent']}%, plugged={battery['power_plugged']}, left={battery['time_left']}")
    print(f"Temperature sensor groups: {len(report['sensors'].get('temperatures', {}))}")
    print(f"Fan sensor groups: {len(report['sensors'].get('fans', {}))}")

    print_heading("GPU")
    gpus = report["gpus"]["nvidia"]
    if not gpus:
        print("No NVIDIA GPU found via nvidia-smi.")
    for gpu in gpus:
        print(
            f"GPU {gpu.get('index')}: {gpu.get('name')} | "
            f"util={gpu.get('utilization.gpu')}% | "
            f"memory={gpu.get('memory_used_human')}/{gpu.get('memory_total_human')} | "
            f"temp={gpu.get('temperature.gpu')}°C"
        )

    if "top_processes" in report:
        print_heading("Top Processes")
        for proc in report["top_processes"]:
            print(
                f"{proc['pid']:>6} {proc['cpu_percent']:>5.1f}% CPU "
                f"{proc['memory_percent']:>5.2f}% MEM  {proc['name']}"
            )


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Print hardware and system information.")
    parser.add_argument("--json", action="store_true", help="Print the report as JSON instead of text.")
    parser.add_argument("--output", type=Path, help="Optional path to save the JSON report.")
    parser.add_argument("--all-partitions", action="store_true", help="Include pseudo and special filesystems.")
    parser.add_argument("--processes", type=int, default=0, metavar="N", help="Include top N processes by CPU usage.")
    parser.add_argument("--cpu-interval", type=float, default=0.5, help="Seconds to sample CPU usage. Default: 0.5")
    return parser.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)
    report = build_report(args)

    if args.output:
        args.output.write_text(json.dumps(report, indent=2), encoding="utf-8")
        print(f"Saved JSON report to {args.output}")

    if args.json:
        print(json.dumps(report, indent=2))
    else:
        print_text_report(report)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())