import os
import platform
import subprocess
from dataclasses import dataclass
from enum import Enum
from typing import Callable
try:
from cligram.utils._device import get_device_info as _native_get_device_info
_NATIVE_AVAILABLE = True
except ImportError:
_NATIVE_AVAILABLE = False
_device_cache: "DeviceInfo | None" = None
class Platform(Enum):
UNKNOWN = "Unknown"
WINDOWS = "Windows"
LINUX = "Linux"
ANDROID = "Android"
MACOS = "macOS"
class Environment(Enum):
LOCAL = "Local"
DOCKER = "Docker"
ACTIONS = "GitHub Actions"
CODESPACES = "Github Codespaces"
VIRTUAL_MACHINE = "Virtual Machine"
WSL = "WSL"
TERMUX = "Termux"
class Architecture(Enum):
UNKNOWN = "unknown"
X86 = "x86"
X64 = "x64"
ARM = "arm"
ARM64 = "arm64"
[docs]
@dataclass
class DeviceInfo:
platform: Platform
architecture: Architecture
name: str
version: str
model: str
environments: list[Environment]
@property
def title(self) -> str:
return f"{self.name} {self.version}"
@property
def is_virtual(self) -> bool:
"""Check if running in a virtual environment."""
virtual_envs = {
Environment.DOCKER,
Environment.VIRTUAL_MACHINE,
Environment.WSL,
}
return any(env in virtual_envs for env in self.environments)
@property
def is_ci(self) -> bool:
"""Check if running in a CI environment."""
ci_envs = {Environment.ACTIONS, Environment.CODESPACES}
return any(env in ci_envs for env in self.environments)
def __post_init__(self):
invalid_models = {
"",
"unknown",
"virtual machine",
"none",
"to be filled by o.e.m.",
"default string",
"system product name",
}
if not self.model or self.model.strip().lower() in invalid_models:
if Environment.VIRTUAL_MACHINE not in self.environments:
self.environments.append(Environment.VIRTUAL_MACHINE)
self.model = platform.node()
def __eq__(self, other: object) -> bool:
if not isinstance(other, DeviceInfo):
return NotImplemented
return (
self.platform == other.platform
and self.architecture == other.architecture
and self.name == other.name
and self.version == other.version
and self.model == other.model
and set(self.environments) == set(other.environments)
)
def __ne__(self, other: object) -> bool:
equal = self.__eq__(other)
if equal is NotImplemented:
return NotImplemented
return not equal
def __hash__(self) -> int:
return hash(
(
self.platform,
self.architecture,
self.name,
self.version,
self.model,
frozenset(self.environments),
)
)
def _read_file_safe(filepath: str, strip_null: bool = False) -> str | None:
"""Safely read a file and return its content."""
try:
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
content = f.read().strip()
if strip_null:
content = content.rstrip("\x00")
return content if content else None
except Exception:
pass
return None
def _read_file_lines(filepath: str) -> list[str]:
"""Safely read a file and return its lines."""
try:
if os.path.exists(filepath):
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
return [line.strip() for line in f.readlines()]
except Exception:
pass
return []
def _run_command(command: list[str], timeout: int = 2) -> str | None:
"""Run a command and return its output safely."""
try:
result = subprocess.run(
command,
capture_output=True,
text=True,
timeout=timeout,
check=False,
)
if result.returncode == 0:
output = result.stdout.strip()
return output if output else None
except Exception:
pass
return None
class WindowsDetector:
"""Windows platform detection and information gathering."""
@staticmethod
def detect() -> tuple[Platform, str, str, str]:
"""Detect Windows-specific information."""
name = "Windows"
version = platform.win32_ver()[0] or platform.release()
model = WindowsDetector.get_model() or platform.node()
return Platform.WINDOWS, name, version, model
@staticmethod
def get_model() -> str | None:
"""Get Windows motherboard/system model from registry."""
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_LOCAL_MACHINE, r"HARDWARE\DESCRIPTION\System\BIOS"
)
value, _ = winreg.QueryValueEx(key, "SystemProductName")
winreg.CloseKey(key)
return value
except Exception:
pass
# Fallback to WMIC
return _run_command(["wmic", "computersystem", "get", "model"])
class LinuxDetector:
"""Linux platform detection and information gathering."""
@staticmethod
def detect() -> tuple[Platform, str, str, str]:
"""Detect Linux-specific information."""
name, version = LinuxDetector.get_distro_info()
model = LinuxDetector.get_device_model() or platform.node()
return Platform.LINUX, name, version, model
@staticmethod
def get_distro_info() -> tuple[str, str]:
"""Get Linux distribution name and version."""
distro_name = "Linux"
distro_version = platform.release()
lines = _read_file_lines("/etc/os-release")
name_value = None
version_value = None
for line in lines:
if line.startswith("NAME="):
name_value = line.split("=", 1)[1].strip('"')
elif line.startswith("VERSION_ID="):
version_value = line.split("=", 1)[1].strip('"')
elif line.startswith("VERSION=") and not version_value:
version_value = line.split("=", 1)[1].strip('"')
if name_value:
distro_name = name_value
if version_value:
distro_version = version_value
return distro_name, distro_version
@staticmethod
def get_device_model() -> str | None:
"""Get Linux device/motherboard model."""
# Try DMI information (x86/x64 systems)
dmi_paths = [
"/sys/class/dmi/id/product_name",
"/sys/class/dmi/id/board_name",
"/sys/devices/virtual/dmi/id/product_name",
"/sys/devices/virtual/dmi/id/board_name",
]
for path in dmi_paths:
model = _read_file_safe(path)
if model and model.lower() not in (
"to be filled by o.e.m.",
"default string",
"system product name",
):
return model
# Try device tree (ARM systems like Raspberry Pi)
device_tree_paths = [
"/proc/device-tree/model",
"/sys/firmware/devicetree/base/model",
]
for path in device_tree_paths:
model = _read_file_safe(path, strip_null=True)
if model:
return model
return None
class AndroidDetector:
"""Android platform detection and information gathering."""
@staticmethod
def is_android() -> bool:
"""Check if running on Android system."""
android_indicators = [
"/system/build.prop",
"/system/bin/app_process",
"/system/framework/framework-res.apk",
]
if any(os.path.exists(path) for path in android_indicators):
return True
if os.getenv("ANDROID_ROOT") or os.getenv("ANDROID_DATA"):
return True
return False
@staticmethod
def detect() -> tuple[Platform, str, str, str]:
"""Detect Android-specific information."""
name = "Android"
version = AndroidDetector.get_version() or platform.release()
model = AndroidDetector.get_device_model() or platform.node()
return Platform.ANDROID, name, version, model
@staticmethod
def get_property(property_name: str) -> str | None:
"""Get Android system property using getprop command."""
return _run_command(["getprop", property_name])
@staticmethod
def get_version() -> str | None:
"""Get Android version from system properties."""
version = AndroidDetector.get_property("ro.build.version.release")
if version:
sdk_version = AndroidDetector.get_property("ro.build.version.sdk")
if sdk_version:
return f"{version} (API {sdk_version})"
return version
@staticmethod
def get_device_model() -> str | None:
"""Get Android device model and manufacturer."""
manufacturer = AndroidDetector.get_property("ro.product.manufacturer")
model = AndroidDetector.get_property(
"ro.product.marketname"
) or AndroidDetector.get_property("ro.product.model")
if manufacturer and model:
# Avoid duplication if model already contains manufacturer
if model.lower().startswith(manufacturer.lower()):
return model
return f"{manufacturer} {model}"
return model or manufacturer
class MacOSDetector:
"""macOS platform detection and information gathering."""
@staticmethod
def detect() -> tuple[Platform, str, str, str]:
"""Detect macOS-specific information."""
name = "macOS"
version = platform.mac_ver()[0] or platform.release()
model = MacOSDetector.get_model() or platform.node()
return Platform.MACOS, name, version, model
@staticmethod
def get_model() -> str | None:
"""Get macOS device model."""
# Try system_profiler
output = _run_command(["system_profiler", "SPHardwareDataType"], timeout=5)
if output:
for line in output.split("\n"):
if "Model Name:" in line:
return line.split(":", 1)[1].strip()
elif "Model Identifier:" in line:
return line.split(":", 1)[1].strip()
# Fallback to sysctl
return _run_command(["sysctl", "-n", "hw.model"])
def _parse_native_result(result: dict) -> DeviceInfo:
"""Convert native C extension result to DeviceInfo object.
Args:
result: Dictionary returned from C extension with keys:
platform, architecture, name, version, model, environments
Returns:
DeviceInfo object with all fields populated.
"""
# Map string values to enum types
platform_map = {
"Windows": Platform.WINDOWS,
"Linux": Platform.LINUX,
"Android": Platform.ANDROID,
"macOS": Platform.MACOS,
"Unknown": Platform.UNKNOWN,
}
arch_map = {
"x64": Architecture.X64,
"x86": Architecture.X86,
"arm64": Architecture.ARM64,
"arm": Architecture.ARM,
"unknown": Architecture.UNKNOWN,
}
env_map = {
"Local": Environment.LOCAL,
"Docker": Environment.DOCKER,
"GitHub Actions": Environment.ACTIONS,
"Github Codespaces": Environment.CODESPACES,
"Virtual Machine": Environment.VIRTUAL_MACHINE,
"WSL": Environment.WSL,
"Termux": Environment.TERMUX,
}
platform = platform_map.get(result["platform"], Platform.UNKNOWN)
architecture = arch_map.get(result["architecture"], Architecture.UNKNOWN)
environments = [env_map.get(e, Environment.LOCAL) for e in result["environments"]]
return DeviceInfo(
platform=platform,
architecture=architecture,
name=result["name"],
version=result["version"],
model=result["model"],
environments=environments,
)
[docs]
def get_device_info(no_cache=False) -> DeviceInfo:
"""Get comprehensive device information across all supported platforms.
Returns:
DeviceInfo: Complete device information including platform, architecture, and environment.
"""
global _device_cache
if not no_cache and isinstance(_device_cache, DeviceInfo):
return _device_cache
if _NATIVE_AVAILABLE:
# Call native C extension
result = _native_get_device_info() # type: ignore
# Convert to DeviceInfo object
device = _parse_native_result(result)
else:
# Fallback to pure Python detection
system = platform.system()
architecture = get_architecture()
environments = _detect_environments()
# Platform-specific detection
if system == "Windows":
plat, name, version, model = WindowsDetector.detect()
elif system == "Linux":
if AndroidDetector.is_android():
plat, name, version, model = AndroidDetector.detect()
else:
plat, name, version, model = LinuxDetector.detect()
elif system == "Darwin":
plat, name, version, model = MacOSDetector.detect()
elif system == "Android":
plat, name, version, model = AndroidDetector.detect()
else:
plat = Platform.UNKNOWN
name = system
version = platform.release()
model = platform.node()
device = DeviceInfo(
platform=plat,
architecture=architecture,
name=name,
version=version,
model=model,
environments=environments,
)
if not no_cache:
_device_cache = device
return device
def get_architecture() -> Architecture:
"""Detect system architecture."""
machine = platform.machine().lower()
architecture_map = {
("amd64", "x86_64", "x64"): Architecture.X64,
("arm64", "aarch64", "armv8", "armv8l", "aarch64_be"): Architecture.ARM64,
("i386", "i686", "x86", "i86pc"): Architecture.X86,
}
for machines, arch in architecture_map.items():
if machine in machines:
return arch
# ARM 32-bit (check with startswith)
if machine.startswith("arm") or machine in ("armv7l", "armv6l", "armv5l"):
return Architecture.ARM
return Architecture.UNKNOWN
def _detect_environments() -> list[Environment]:
"""Detect all active environments."""
environments: list[Environment] = []
# Environment detection rules
env_checks: list[tuple[Callable[[], bool], Environment]] = [
(lambda: os.getenv("CODESPACES") == "true", Environment.CODESPACES),
(lambda: os.getenv("GITHUB_ACTIONS") == "true", Environment.ACTIONS),
(
lambda: os.path.exists("/.dockerenv") or os.path.exists("/.containerenv"),
Environment.DOCKER,
),
(lambda: os.getenv("WSL_DISTRO_NAME") is not None, Environment.WSL),
(lambda: os.getenv("TERMUX_VERSION") is not None, Environment.TERMUX),
]
for check, env in env_checks:
try:
if check():
environments.append(env)
except Exception:
continue
return environments if environments else [Environment.LOCAL]