diff --git a/lib/sunhpc/sunhpc/commands/__init__.py b/lib/sunhpc/sunhpc/commands/__init__.py index 3fbc549..f3d0897 100644 --- a/lib/sunhpc/sunhpc/commands/__init__.py +++ b/lib/sunhpc/sunhpc/commands/__init__.py @@ -3,9 +3,23 @@ import re import sys import pwd import xml +import yaml +import types import syslog +import inspect +import argparse import sunhpc.util +from io import StringIO +from typing import Dict + +from sunhpc.logs import Logs +from sunhpc.disks import DiskInfo +from sunhpc.configs import Config +from sunhpc.network import Network +from sunhpc.memory import MemoryInfo +from sunhpc.output import Output + from xml.sax import saxutils from xml.sax import handler from xml.sax import make_parser @@ -312,21 +326,33 @@ class Command: MustBeRoot = 1 def __init__(self, database): - self.db = database + self.db = database + self.conf = Config('/etc/sunhpc/sunhpc.yaml') + self.text = '' + self.output = [] - self.text = '' - self.output = [] + self.net = Network() + self.mem = MemoryInfo() + self.disk = DiskInfo() + self.fmt = Output() - self.os = os.uname()[0].lower() - self.arch = os.uname()[4] - self.user = pwd.getpwuid(os.getuid()).pw_name + self.os = os.uname()[0].lower() + self.arch = os.uname()[4] + self.user = pwd.getpwuid(os.getuid()).pw_name - self._args = None - self._params = None + self.parser = argparse.ArgumentParser() + self.subcommand = 'Sub Command' + self.subdesc = None + self.epilog = None + + self._args = None + self._params = None if 'SUNHPCDEBUG' in os.environ: self._debug = True + self.log = Logs(name='Sunhpc', level=0, show_time=True) else: self._debug = False + self.log = Logs(name='Sunhpc', show_time=True) def debug(self): """Return True if debug mode is enabled""" @@ -366,6 +392,55 @@ class Command: else: return 'no' + def dtons(self, data: dict) -> types.SimpleNamespace: + '''递归将字典转换为 SimpleNamespace''' + if isinstance(data, dict): + for key, val in data.items(): + clean_key = key.replace(' ','_') + data[clean_key] = self.dtons(val) + return types.SimpleNamespace(**data) + elif isinstance(data, list): + return [self.dtons(e) for e in data] + return data + + def parser_cmd_init(self): + self.parser.usage = 'sunhpc %s [options]' % self.subcommand + if self.subdesc: + self.parser.description = self.subdesc + + # 重新设置 formatter 为 RawTextHelpFormatter + self.parser.formatter_class = argparse.RawTextHelpFormatter + + if self.epilog is None: + epilogmsg = "\n" + epilogmsg += "Help & Documentation:\n" + epilogmsg += " - Gitea Repository : https://gitea.sunhpc.com/py/sunhpc\n" + epilogmsg += " - Documentation : https://gitea.sunhpc.com/py/sunhpc/wiki\n" + epilogmsg += " - Issue Tracker : https://gitea.sunhpc.com/py/sunhpc/issues\n" + #epilogmsg += "SunHPC Tool v1.0.0\n" + #epilogmsg += "Copyright (c) 2026 SunHPC Team\n" + #epilogmsg += "This is open-source software released under the Apache 2.0 license.\n" + self.parser.epilog = epilogmsg + + def parser_cmd_args(self, args): + + original_stderr = sys.stderr + sys.stderr = StringIO() + try: + parsed, unknown = self.parser.parse_known_args(args) + result = vars(parsed) # 返回字典格式 + result['unknown'] = unknown + return result + #return vars(parsed) # 返回字典格式 + except SystemExit as e: + error_msg = sys.stderr.getvalue() + sys.stderr = original_stderr + if e.code != 0: + self.log.error(error_msg) + #pass + #return {} + #return {}, args + def clearText(self): self.text = '' def addText(self, s): @@ -600,6 +675,9 @@ class Command: return rlist def runWrapper(self, name, args): + + self.subcommand = name + if args: command = '%s %s' % (name, ' '.join(args)) else: @@ -689,4 +767,4 @@ class Command: elif format == 'sphinx': self.addText(handler.getSphinxText()) else: - self.addText(handler.getPlainText()) \ No newline at end of file + self.addText(handler.getPlainText()) diff --git a/lib/sunhpc/sunhpc/commands/init/config/__init__.py b/lib/sunhpc/sunhpc/commands/init/config/__init__.py index 7435706..10a8cff 100644 --- a/lib/sunhpc/sunhpc/commands/init/config/__init__.py +++ b/lib/sunhpc/sunhpc/commands/init/config/__init__.py @@ -3,6 +3,8 @@ import re import sys import sunhpc.commands +from sunhpc.configs import Config + class Command(sunhpc.commands.init.command): """ 这个命令是初始化系统的相关配置,并且写入到配置文件. @@ -22,6 +24,12 @@ class Command(sunhpc.commands.init.command): 提供一个系统ISO文件、或者挂载路径、例如: /mnt/cdrom + + -h --help 显示帮助信息 + -d --debug 开启调试模式 + -v --verbose 显示详细信息 + + 使用eth1网络接口、和 /mnt/cdrom 挂载路径 @@ -30,6 +38,80 @@ class Command(sunhpc.commands.init.command): 使用eth1网络接口、和 /mnt/cdrom 挂载路径 """ - def run(self, params, args): - print ('This is init opt ...') \ No newline at end of file + self.parser_cmd_init() + self.parser.add_argument('-i', '--iface', action='store', help='Interface name') + self.parser.add_argument('-d', '--debug', action='store_true', help='Debug mode') + self.parser.add_argument('-f', '--file', action='store_true', help='Configuration file') + + self.parser.add_argument( + 'iface', + help='Network interface (required, e.g., eth0, eth1)' + ) + + self.parser.add_argument( + 'mnt', + nargs='?', + default='/mnt/cdrom', + help='Mount point (optional, default: /mnt/cdrom)' + ) + + parsed_params = self.parser_cmd_args(args) + + self.log.info('File "/opt/sunhpc/lib/sunhpc/sunhpc/commands/init/config/__init__.py", line 63, in run File "/opt/sunhpc/lib/sunhpc/sunhpc/commands/init/config/__init__.py", line 63, in run') + #print (parsed_params) + iface = parsed_params.get('iface', 'eth0') + + + ''' + config = Config() + config.iface = 'eth1' + config.mnt = '/mnt/cdrom' + config.network = '1.1.1.1' + config.netmask = '255.255.255.0' + config.gateway = '1.1.1.1' + + config.dhcp = Config() + config.dhcp.enabled = True + config.dhcp.server = '1.1.1.1' + config.dhcp.options = ['domain-name', 'domain-name-servers'] + + config.http = Config() + config.http.enabled = True + config.http.server = '1.1.1.1' + + config.api = Config() + config.api.enabled = True + config.api.server = '1.1.1.1' + + config.save('/tmp/sunhpc.yaml') + + self.net.setiface('eth0') + print (f'Interface: {self.net.iface}') + print (f'IP : {self.net.getip()}') + print (f'Network: {self.net.getnetwork()}') + print (f'Netmask: {self.net.getnetmask()}') + print (f'Gateway: {self.net.getgateway()}') + print (f'MAC : {self.net.getmac()}') + print (f'CIDR : {self.net.getcidr()}') + print (f'IPv6 : {self.net.getipv6()}') + + rx, tx = self.net.get_transfer_human() + print (f'RX : {rx}') + print (f'TX : {tx}') + + #print (self.disk.get_disk_info('sda')) + #print (self.mem.get_memory_summary()) + #print (self.mem.get_memory_info()) + + #self.fmt.add_header(['Interface', 'IP', 'Network', 'Netmask', 'Gateway', 'MAC', 'CIDR', 'IPv6']) + + d1 = { + 'name', 'alice', + 'age', 18, + 'city', 'beijing', + 'occupation', 'student' + } + + self.fmt.dict_output(d1) + ''' \ No newline at end of file diff --git a/lib/sunhpc/sunhpc/configs.py b/lib/sunhpc/sunhpc/configs.py new file mode 100644 index 0000000..caa3d25 --- /dev/null +++ b/lib/sunhpc/sunhpc/configs.py @@ -0,0 +1,378 @@ +import os +import yaml +from typing import Any, Optional, Dict, List +from pathlib import Path +from sunhpc.logs import Logs + +slog = Logs(name="Config", show_time=True) + +class Config: + """功能完整的配置管理器(支持动态创建属性)""" + + def __init__(self, config_path: Optional[str] = None, auto_load: bool = True): + self._config_path = config_path + self._data = {} + self._initialized = False + + if config_path and auto_load and os.path.exists(config_path): + self.load(config_path) + else: + # 初始化空配置 + self._initialized = True + + def load(self, path: str): + """从 YAML 文件加载配置""" + with open(path, 'r', encoding='utf-8') as f: + self._data = yaml.safe_load(f) + # 清空现有属性 + for attr in list(self.__dict__.keys()): + if not attr.startswith('_'): + delattr(self, attr) + self._build_attributes() + self._initialized = True + + def _build_attributes(self): + """构建属性访问""" + self._build_from_dict(self._data) + + def _build_from_dict(self, data: dict, parent_obj=None): + """递归构建属性""" + if parent_obj is None: + parent_obj = self + + for key, value in data.items(): + # 处理键名中的空格 + clean_key = key.replace(' ', '_') + + if isinstance(value, dict): + # 创建子对象 + sub_obj = Config() + sub_obj._build_from_dict(value) + setattr(parent_obj, clean_key, sub_obj) + elif isinstance(value, list): + # 转换列表中的字典 + converted_list = [] + for item in value: + if isinstance(item, dict): + sub_obj = Config() + sub_obj._build_from_dict(item) + converted_list.append(sub_obj) + else: + converted_list.append(item) + setattr(parent_obj, clean_key, converted_list) + else: + setattr(parent_obj, clean_key, value) + + def __setattr__(self, name: str, value: Any): + """拦截属性设置,支持动态创建配置项""" + # 允许设置私有属性和已初始化的内部属性 + if name.startswith('_') or name in ['_config_path', '_data', '_initialized']: + super().__setattr__(name, value) + return + + # 动态创建配置项 + if isinstance(value, dict): + # 如果设置的是字典,转换为 Config 对象 + sub_obj = Config() + sub_obj._build_from_dict(value) + super().__setattr__(name, sub_obj) + elif isinstance(value, list): + # 处理列表 + converted_list = [] + for item in value: + if isinstance(item, dict): + sub_obj = Config() + sub_obj._build_from_dict(item) + converted_list.append(sub_obj) + else: + converted_list.append(item) + super().__setattr__(name, converted_list) + else: + super().__setattr__(name, value) + + # 标记数据已修改 + self._initialized = True + + def __getattr__(self, name: str): + """访问不存在的属性时返回 None 而不是报错""" + # 如果属性不存在,返回 None(可选行为) + return None + + def save(self, path: Optional[str] = None): + """保存配置到文件""" + save_path = path or self._config_path + if not save_path: + raise ValueError("未指定保存路径") + + # 确保目录存在 + Path(save_path).parent.mkdir(parents=True, exist_ok=True) + + with open(save_path, 'w', encoding='utf-8') as f: + yaml.dump(self.to_dict(), f, default_flow_style=False, indent=2, allow_unicode=True) + + print(f"配置已保存到: {save_path}") + + def to_dict(self) -> dict: + """转换回字典""" + result = {} + for key, value in self.__dict__.items(): + # 跳过私有属性 + if key.startswith('_'): + continue + + if isinstance(value, Config): + result[key] = value.to_dict() + elif isinstance(value, list): + converted_list = [] + for item in value: + if isinstance(item, Config): + converted_list.append(item.to_dict()) + else: + converted_list.append(item) + result[key] = converted_list + else: + result[key] = value + return result + + def create_section(self, section_name: str): + """创建一个新的配置节""" + if not hasattr(self, section_name): + setattr(self, section_name, Config()) + return getattr(self, section_name) + + def set_nested(self, path: str, value: Any): + """通过点号路径设置值,例如: set_nested('network.interface', 'eth0')""" + parts = path.split('.') + obj = self + + # 导航到父对象 + for part in parts[:-1]: + if not hasattr(obj, part): + setattr(obj, part, Config()) + obj = getattr(obj, part) + + # 设置最终值 + setattr(obj, parts[-1], value) + + def get_nested(self, path: str, default: Any = None) -> Any: + """通过点号路径获取值""" + parts = path.split('.') + obj = self + + for part in parts: + if not hasattr(obj, part): + return default + obj = getattr(obj, part) + + return obj + + def __repr__(self): + return f"Config({self.to_dict()})" + + def print_tree(self, indent: int = 0): + """打印配置树(调试用)""" + for key, value in self.__dict__.items(): + if key.startswith('_'): + continue + print(' ' * indent + f"{key}:", end=' ') + if isinstance(value, Config): + print() + value.print_tree(indent + 1) + elif isinstance(value, list): + print(f"[{len(value)} items]") + for i, item in enumerate(value): + print(' ' * (indent + 1) + f"[{i}]:", end=' ') + if isinstance(item, Config): + print() + item.print_tree(indent + 2) + else: + print(item) + else: + print(value) + + +# ============= 使用示例 ============= +def main(): + # 1. 创建新配置(不加载现有文件) + config = Config() # 空配置 + + # 2. 直接设置属性(会自动创建) + config.interface = 'eth1' + config.network = '1.1.1.1' + config.netmask = '255.255.255.0' + config.gateway = '192.168.1.1' + + # 3. 创建嵌套配置 + config.dhcp = Config() + config.dhcp.enabled = True + config.dhcp.start = '192.168.1.100' + config.dhcp.end = '192.168.1.200' + + # 4. 或者通过点号路径设置(更简洁) + config.set_nested('warewulf.port', 9873) + config.set_nested('warewulf.secure', False) + config.set_nested('warewulf.autobuild_overlays', True) + + # 5. 创建列表配置 + config.dns_servers = ['8.8.8.8', '8.8.4.4'] + + # 6. 创建复杂的列表字典结构 + config.mounts = [ + {'source': '/etc/resolv.conf', 'dest': '/etc/resolv.conf', 'readonly': True}, + {'source': '/etc/hosts', 'dest': '/etc/hosts', 'readonly': True} + ] + + # 7. 使用 create_section 方法 + nfs_section = config.create_section('nfs') + nfs_section.enabled = True + nfs_section.export_paths = [ + {'path': '/home', 'options': 'rw,sync'}, + {'path': '/opt', 'options': 'ro,sync'} + ] + + # 8. 直接访问和修改 + print("=" * 50) + print("配置访问示例:") + print(f"interface: {config.interface}") + print(f"network: {config.network}") + print(f"DHCP enabled: {config.dhcp.enabled}") + print(f"Warewulf port: {config.warewulf.port}") + print(f"DNS servers: {config.dns_servers}") + + # 9. 修改已存在的配置 + config.network = '2.2.2.0' + config.dhcp.enabled = False + config.warewulf.secure = True + + # 10. 添加更多配置 + config.api = Config() + config.api.enabled = True + config.api.allowed_subnets = ['10.0.0.0/8', '192.168.0.0/16'] + + config.ssh = Config() + config.ssh.key_types = ['ed25519', 'rsa', 'ecdsa'] + + # 11. 保存配置 + config.save('config_initial.yaml') + + # 12. 重新加载配置 + print("\n" + "=" * 50) + print("重新加载配置:") + new_config = Config('config_initial.yaml') + print(f"加载的 interface: {new_config.interface}") + print(f"加载的 network: {new_config.network}") + print(f"加载的 DHCP enabled: {new_config.dhcp.enabled}") + print(f"加载的 Warewulf port: {new_config.warewulf.port}") + + # 13. 配置树形展示 + print("\n" + "=" * 50) + print("配置树形结构:") + new_config.print_tree() + + # 14. 使用 get_nested 安全访问 + print("\n" + "=" * 50) + print("安全访问示例:") + port = new_config.get_nested('warewulf.port', 9090) + print(f"warewulf.port: {port}") + not_exist = new_config.get_nested('nonexist.key', 'default_value') + print(f"不存在的键: {not_exist}") + + +# ============= 批量初始化配置 ============= +class ConfigInitializer: + """配置初始化器,支持字典或关键字参数批量设置""" + + @staticmethod + def from_dict(config_dict: dict) -> Config: + """从字典创建配置""" + config = Config() + ConfigInitializer._dict_to_config(config, config_dict) + return config + + @staticmethod + def _dict_to_config(config_obj: Config, data: dict): + """递归将字典转换为配置对象""" + for key, value in data.items(): + if isinstance(value, dict): + sub_obj = Config() + ConfigInitializer._dict_to_config(sub_obj, value) + setattr(config_obj, key, sub_obj) + elif isinstance(value, list): + converted_list = [] + for item in value: + if isinstance(item, dict): + sub_obj = Config() + ConfigInitializer._dict_to_config(sub_obj, item) + converted_list.append(sub_obj) + else: + converted_list.append(item) + setattr(config_obj, key, converted_list) + else: + setattr(config_obj, key, value) + + @staticmethod + def from_yaml_template(template_path: str) -> Config: + """从 YAML 模板文件初始化配置""" + with open(template_path, 'r', encoding='utf-8') as f: + data = yaml.safe_load(f) + return ConfigInitializer.from_dict(data) + + +# ============= 快速初始化示例 ============= + +def quick_init_example(): + """快速初始化示例""" + + # 方式1: 逐个设置(你要求的方式) + config = Config() + config.interface = 'eth1' + config.network = '1.1.1.1' + config.netmask = '255.255.255.0' + config.gateway = '192.168.1.1' + config.dhcp_enabled = True + config.dhcp_start = '192.168.1.100' + config.dhcp_end = '192.168.1.200' + config.save('config_by_assign.yaml') + print("方式1: 逐个赋值保存完成") + + # 方式2: 使用 set_nested 批量设置 + config2 = Config() + settings = { + 'interface': 'eth0', + 'network': '10.0.0.0', + 'netmask': '255.0.0.0', + 'dhcp.enabled': True, + 'dhcp.range_start': '10.0.0.100', + 'dhcp.range_end': '10.0.0.200', + 'warewulf.port': 9873, + 'warewulf.secure': True + } + for path, value in settings.items(): + config2.set_nested(path, value) + config2.save('config_by_set_nested.yaml') + print("方式2: set_nested 批量设置完成") + + # 方式3: 从字典初始化 + config_dict = { + 'interface': 'eth2', + 'network': '172.16.0.0', + 'netmask': '255.255.240.0', + 'dhcp': { + 'enabled': True, + 'start': '172.16.0.100', + 'end': '172.16.0.200' + }, + 'warewulf': { + 'port': 9873, + 'secure': False + } + } + config3 = ConfigInitializer.from_dict(config_dict) + config3.save('config_from_dict.yaml') + print("方式3: 从字典初始化完成") + + +if __name__ == '__main__': + main() + print("\n" + "=" * 50) + quick_init_example() \ No newline at end of file diff --git a/lib/sunhpc/sunhpc/disks.py b/lib/sunhpc/sunhpc/disks.py new file mode 100644 index 0000000..ca308e5 --- /dev/null +++ b/lib/sunhpc/sunhpc/disks.py @@ -0,0 +1,717 @@ +#!/usr/bin/env python3 +""" +DiskInfo class to retrieve comprehensive disk information. +Supports physical disks, SSDs, and virtual disks (VMware, VirtualBox, KVM). +No third-party libraries required, uses /sys, /proc, and system commands. +""" + +import os +import re +import glob +import subprocess +from typing import Optional, Dict, List, Tuple, Any + + +class DiskInfo: + """Class to retrieve detailed information about disks.""" + + def __init__(self): + self.disk_name: Optional[str] = None + self._info_cache: Dict[str, Any] = {} + + def get_all_disks(self) -> List[str]: + """ + Get list of all disk devices (excluding partitions). + Returns disk names like ['sda', 'sdb', 'vda', 'nvme0n1']. + """ + disks = [] + + # Method 1: /sys/block/ + try: + for path in glob.glob('/sys/block/*'): + disk = os.path.basename(path) + # Skip loop devices, ram disks, and removable drives if needed + if not disk.startswith(('loop', 'ram', 'sr')): + # Check if it's a real disk (has device directory) + if os.path.exists(f'/sys/block/{disk}/device'): + disks.append(disk) + except Exception: + pass + + # Method 2: /proc/partitions (for additional disks) + try: + with open('/proc/partitions', 'r') as f: + for line in f: + parts = line.strip().split() + if len(parts) >= 4: + device = parts[3] + # Major number 8 = SCSI/SATA, 9 = MD, 11 = SCSI CD-ROM, 259 = NVMe + major = int(parts[1]) if parts[1].isdigit() else 0 + if (major in (8, 259) or device.startswith(('sd', 'hd', 'vd', 'nvme'))) and \ + device not in disks and not device[-1].isdigit(): + disks.append(device) + except Exception: + pass + + return sorted(set(disks)) + + def set_disk(self, disk_name: str) -> 'DiskInfo': + """Set specific disk to query.""" + self.disk_name = disk_name + self._info_cache = {} + return self + + def get_disk_info(self, disk_name: Optional[str] = None) -> Optional[Dict[str, Any]]: + """ + Get comprehensive information for a disk. + If disk_name is None, uses the currently set disk. + Returns dictionary with all available information. + """ + target_disk = disk_name or self.disk_name + if not target_disk: + # Return info for all disks + all_info = {} + for disk in self.get_all_disks(): + all_info[disk] = self.get_disk_info(disk) + return all_info + + # Check cache + if target_disk in self._info_cache: + return self._info_cache[target_disk] + + # Gather all information + info = { + 'name': target_disk, + 'model': '', + 'serial': '', + 'size_bytes': None, + 'size_human': None, + 'firmware': None, + 'interface': None, + 'type': None, # HDD, SSD, NVMe, Virtual + 'rotational': None, # True for HDD, False for SSD + 'rpm': None, # Rotational speed for HDD + 'sector_size': None, + 'sector_size_logical': None, + 'sector_size_physical': None, + 'smart_supported': None, + 'smart_enabled': None, + 'temperature': None, + 'power_on_hours': None, + 'power_on_count': None, + 'bad_sectors': None, # Reallocated sectors count + 'pending_sectors': None, # Pending reallocation + 'uncorrectable_sectors': None, + 'health_status': None, # PASSED, FAILED, UNKNOWN + 'vendor': '', + 'removable': None, + 'wwn': '', # World Wide Name + 'device_path': None, + 'partitions': None, + 'is_virtual': False, + 'virtual_type': None, # VMware, VirtualBox, KVM, etc. + } + + # Try to get information from sysfs (most reliable for basic info) + self._get_info_from_sysfs(target_disk, info) + + # Try to get SMART information (requires smartctl) + self._get_smart_info(target_disk, info) + + # Try to get additional info from /proc/scsi/scsi + self._get_scsi_info(target_disk, info) + + # Detect virtualization + self._detect_virtualization(target_disk, info) + + # Get partition information + info['partitions'] = self._get_partitions(target_disk) + + # Cache the results + self._info_cache[target_disk] = info + + return info + + def _get_info_from_sysfs(self, disk: str, info: Dict[str, Any]): + """Get disk information from sysfs (/sys/block/).""" + base_path = f'/sys/block/{disk}' + + if not os.path.exists(base_path): + return + + # Device path + info['device_path'] = f'/dev/{disk}' + + # Size in sectors (each sector usually 512 bytes) + size_path = f'{base_path}/size' + if os.path.exists(size_path): + try: + with open(size_path, 'r') as f: + sectors = int(f.read().strip()) + sector_size = 512 # Default standard + # Try to get actual sector size + if os.path.exists(f'{base_path}/queue/hw_sector_size'): + with open(f'{base_path}/queue/hw_sector_size', 'r') as f2: + sector_size = int(f2.read().strip()) + info['size_bytes'] = sectors * sector_size + info['size_human'] = self._bytes_to_human(info['size_bytes']) + info['sector_size'] = sector_size + except Exception: + pass + + # Logical and physical sector size + logical_path = f'{base_path}/queue/logical_block_size' + physical_path = f'{base_path}/queue/physical_block_size' + if os.path.exists(logical_path): + try: + with open(logical_path, 'r') as f: + info['sector_size_logical'] = int(f.read().strip()) + except Exception: + pass + if os.path.exists(physical_path): + try: + with open(physical_path, 'r') as f: + info['sector_size_physical'] = int(f.read().strip()) + except Exception: + pass + + # Rotational (spinning disk vs SSD) + rotational_path = f'{base_path}/queue/rotational' + if os.path.exists(rotational_path): + try: + with open(rotational_path, 'r') as f: + rotational = int(f.read().strip()) + info['rotational'] = rotational == 1 + info['type'] = 'HDD' if rotational else 'SSD' + except Exception: + pass + + # Removable + removable_path = f'{base_path}/removable' + if os.path.exists(removable_path): + try: + with open(removable_path, 'r') as f: + info['removable'] = int(f.read().strip()) == 1 + except Exception: + pass + + # Model from device directory + device_path = f'{base_path}/device' + if os.path.exists(device_path): + # Try different model file locations + for model_file in ['model', 'product', 'inquiry', 'device/model']: + full_path = os.path.join(device_path, model_file) + if os.path.exists(full_path): + try: + with open(full_path, 'r') as f: + model = f.read().strip() + if model and not model.isspace(): + info['model'] = model + break + except Exception: + pass + + # Firmware version + for fw_file in ['fwrev', 'firmware_rev', 'rev']: + full_path = os.path.join(device_path, fw_file) + if os.path.exists(full_path): + try: + with open(full_path, 'r') as f: + info['firmware'] = f.read().strip() + break + except Exception: + pass + + # Serial number + for serial_file in ['serial', 'wwid']: + full_path = os.path.join(device_path, serial_file) + if os.path.exists(full_path): + try: + serial = f.read().strip() if os.path.isfile(full_path) else None + if serial and not serial.isspace(): + info['serial'] = serial + break + except Exception: + pass + + # WWN (World Wide Name) + wwn_path = f'{base_path}/wwid' + if os.path.exists(wwn_path): + try: + with open(wwn_path, 'r') as f: + wwn = f.read().strip() + if wwn.startswith('naa.'): + info['wwn'] = wwn + except Exception: + pass + + # Interface type + if os.path.exists(f'{base_path}/device/subsystem'): + try: + subsystem = os.path.realpath(f'{base_path}/device/subsystem') + if 'nvme' in subsystem: + info['interface'] = 'NVMe' + elif 'ata' in subsystem or 'scsi' in subsystem: + # Determine SATA or SAS + if os.path.exists(f'{device_path}/sas_address'): + info['interface'] = 'SAS' + else: + info['interface'] = 'SATA' + elif 'virtio' in subsystem: + info['interface'] = 'VirtIO' + except Exception: + pass + + def _get_scsi_info(self, disk: str, info: Dict[str, Any]): + """Get SCSI disk information from /proc/scsi/scsi.""" + try: + with open('/proc/scsi/scsi', 'r') as f: + content = f.read() + + # Find matching disk entry + patterns = [ + rf'Host:\s+scsi\d+\s+Channel:\s+\d+\s+Id:\s+\d+\s+Lun:\s+\d+\s*\n\s*Vendor:\s+(\S+)\s+Model:\s+(.+?)\s+Rev:\s+(\S+)\s*\n\s*Type:\s+\S+\s+ANSI\s+SCSI\s+revision:\s+\d+', + ] + + for pattern in patterns: + matches = re.findall(pattern, content, re.MULTILINE) + for vendor, model, rev in matches: + if disk in model.lower() or disk in vendor.lower(): + info['vendor'] = vendor.strip() + if not info['model']: + info['model'] = model.strip() + if not info['firmware']: + info['firmware'] = rev.strip() + break + except Exception: + pass + + def _get_smart_info(self, disk: str, info: Dict[str, Any]): + """ + Get SMART information using smartctl command. + This requires smartmontools package to be installed. + """ + if not self._check_command_exists('smartctl'): + info['smart_supported'] = False + return + + try: + # Check if SMART is supported + result = subprocess.run( + ['smartctl', '-i', f'/dev/{disk}'], + capture_output=True, + text=True, + timeout=10 + ) + + if 'SMART support is:' in result.stdout: + smart_lines = result.stdout + if 'SMART support is: Available' in smart_lines: + info['smart_supported'] = True + if 'SMART support is: Enabled' in smart_lines: + info['smart_enabled'] = True + + # Get health information + result = subprocess.run( + ['smartctl', '-H', f'/dev/{disk}'], + capture_output=True, + text=True, + timeout=10 + ) + + if 'SMART overall-health self-assessment test result:' in result.stdout: + health_line = re.search( + r'SMART overall-health self-assessment test result:\s+(.+)', + result.stdout + ) + if health_line: + info['health_status'] = health_line.group(1).strip() + + # Get detailed SMART attributes + result = subprocess.run( + ['smartctl', '-A', f'/dev/{disk}'], + capture_output=True, + text=True, + timeout=10 + ) + + # Parse SMART attributes + attributes = { + 'Reallocated_Sector_Ct': 'bad_sectors', + 'Current_Pending_Sector': 'pending_sectors', + 'Offline_Uncorrectable': 'uncorrectable_sectors', + 'Temperature_Celsius': 'temperature', + 'Power_On_Hours': 'power_on_hours', + 'Power_Cycle_Count': 'power_on_count', + 'Rotational_Rate': 'rpm' + } + + for line in result.stdout.split('\n'): + for attr, key in attributes.items(): + if attr in line: + # Parse the attribute value (usually the 10th column) + parts = line.split() + if len(parts) >= 10: + try: + value = parts[9] # RAW_VALUE column + if value.isdigit(): + if key == 'rpm' and int(value) > 0: + info['rpm'] = int(value) + if info['type'] == 'HDD': + info['type'] = 'HDD' + elif key != 'rpm': + info[key] = int(value) + except (ValueError, IndexError): + pass + break + + # Try to get rotational rate for HDDs from other sources + if info.get('rotational') and not info.get('rpm'): + # Try to get from sysfs + rpm_path = f'/sys/block/{disk}/device/rotational_rate' + if os.path.exists(rpm_path): + try: + with open(rpm_path, 'r') as f: + rpm = int(f.read().strip()) + if rpm > 0: + info['rpm'] = rpm + except Exception: + pass + + except subprocess.TimeoutExpired: + pass + except FileNotFoundError: + info['smart_supported'] = False + except Exception: + pass + + def _detect_virtualization(self, disk: str, info: Dict[str, Any]): + """Detect if disk is virtual (VMware, VirtualBox, KVM, etc.).""" + info['is_virtual'] = False + + # Check disk model for virtualization indicators + model = info.get('model', '').lower() + vendor = info.get('vendor', '').lower() + + if 'vmware' in model or 'vmware' in vendor: + info['is_virtual'] = True + info['virtual_type'] = 'VMware' + info['type'] = 'Virtual' + elif 'vbox' in model or 'virtualbox' in model: + info['is_virtual'] = True + info['virtual_type'] = 'VirtualBox' + info['type'] = 'Virtual' + elif 'qemu' in model or 'kvm' in model: + info['is_virtual'] = True + info['virtual_type'] = 'KVM/QEMU' + info['type'] = 'Virtual' + elif 'virtio' in model: + info['is_virtual'] = True + info['virtual_type'] = 'VirtIO' + info['type'] = 'Virtual' + + # Check system virtualization + try: + with open('/proc/cpuinfo', 'r') as f: + if 'hypervisor' in f.read().lower(): + if not info['is_virtual']: + info['is_virtual'] = True + info['virtual_type'] = 'Unknown Hypervisor' + except Exception: + pass + + def _get_partitions(self, disk: str) -> Optional[List[Dict[str, Any]]]: + """Get partition information for the disk.""" + partitions = [] + + try: + # Read from /proc/partitions + with open('/proc/partitions', 'r') as f: + for line in f: + parts = line.strip().split() + if len(parts) >= 4: + device = parts[3] + if device.startswith(disk) and device != disk: + size_kb = int(parts[2]) if parts[2].isdigit() else 0 + partitions.append({ + 'name': device, + 'size_kb': size_kb, + 'size_human': self._bytes_to_human(size_kb * 1024) + }) + except Exception: + pass + + return partitions if partitions else None + + def get_disk_summary(self, disk_name: Optional[str] = None) -> str: + """Get a human-readable summary for a disk.""" + info = self.get_disk_info(disk_name) + + if not info: + return f"No information available for disk: {disk_name}" + + if isinstance(info, dict) and 'name' in info: + # Single disk + return self._format_disk_summary(info) + elif isinstance(info, dict): + # Multiple disks + summaries = [] + for disk, disk_info in info.items(): + summaries.append(self._format_disk_summary(disk_info)) + return '\n\n'.join(summaries) + + return "No disk information available" + + def _format_disk_summary(self, info: Dict[str, Any]) -> str: + """Format disk information as a human-readable string.""" + lines = [] + lines.append(f"Disk: {info['name']} ({info['device_path']})") + lines.append(f"{'=' * 60}") + + if info.get('model'): + lines.append(f" Model: {info['model']}") + if info.get('vendor'): + lines.append(f" Vendor: {info['vendor']}") + if info.get('serial'): + lines.append(f" Serial Number: {info['serial']}") + if info.get('firmware'): + lines.append(f" Firmware: {info['firmware']}") + if info.get('wwn'): + lines.append(f" WWN: {info['wwn']}") + + lines.append(f" Type: {info.get('type', 'Unknown')}") + if info.get('rotational') is not None: + lines.append(f" Rotational: {'Yes' if info['rotational'] else 'No'}") + if info.get('rpm'): + lines.append(f" RPM: {info['rpm']}") + + if info.get('size_human'): + lines.append(f" Size: {info['size_human']} ({info.get('size_bytes', 0):,} bytes)") + + if info.get('interface'): + lines.append(f" Interface: {info['interface']}") + + if info.get('sector_size'): + lines.append(f" Sector Size: {info['sector_size']} bytes") + if info.get('sector_size_logical'): + lines.append(f" Logical Sector Size: {info['sector_size_logical']} bytes") + if info.get('sector_size_physical'): + lines.append(f" Physical Sector Size: {info['sector_size_physical']} bytes") + + lines.append(f" Removable: {'Yes' if info.get('removable') else 'No'}") + lines.append(f" Virtual: {'Yes' if info.get('is_virtual') else 'No'}") + if info.get('virtual_type'): + lines.append(f" Virtual Type: {info['virtual_type']}") + + # SMART information + if info.get('smart_supported'): + lines.append(" SMART:") + lines.append(f" Supported: Yes") + lines.append(f" Enabled: {'Yes' if info.get('smart_enabled') else 'No'}") + if info.get('health_status'): + lines.append(f" Health: {info['health_status']}") + if info.get('temperature'): + lines.append(f" Temperature: {info['temperature']}°C") + if info.get('power_on_hours'): + days = info['power_on_hours'] // 24 + lines.append(f" Power On Hours: {info['power_on_hours']} hours ({days} days)") + if info.get('power_on_count'): + lines.append(f" Power Cycle Count: {info['power_on_count']}") + + # Bad sector information + bad_info = [] + if info.get('bad_sectors'): + bad_info.append(f"Reallocated: {info['bad_sectors']}") + if info.get('pending_sectors'): + bad_info.append(f"Pending: {info['pending_sectors']}") + if info.get('uncorrectable_sectors'): + bad_info.append(f"Uncorrectable: {info['uncorrectable_sectors']}") + + if bad_info: + lines.append(f" Bad Sectors: {', '.join(bad_info)}") + else: + lines.append(" SMART: Not supported or smartctl not installed") + + # Partitions + if info.get('partitions'): + lines.append(f" Partitions: {len(info['partitions'])}") + for part in info['partitions'][:5]: # Show first 5 + lines.append(f" - {part['name']}: {part['size_human']}") + if len(info['partitions']) > 5: + lines.append(f" ... and {len(info['partitions']) - 5} more") + + return '\n'.join(lines) + + def check_disk_health(self, disk_name: Optional[str] = None) -> Dict[str, Any]: + """ + Check disk health and return a summary. + Returns dictionary with health status and recommendations. + """ + info = self.get_disk_info(disk_name) + + if not info or (isinstance(info, dict) and 'name' not in info): + return {'error': 'No disk information available'} + + if isinstance(info, dict) and 'name' in info: + disks_info = {info['name']: info} + else: + disks_info = info + + health_summary = {} + + for disk_name, disk_info in disks_info.items(): + health = { + 'disk': disk_name, + 'status': 'UNKNOWN', + 'issues': [], + 'warnings': [], + 'recommendations': [] + } + + # Check SMART health status + if disk_info.get('health_status'): + if 'PASSED' in disk_info['health_status']: + health['status'] = 'GOOD' + elif 'FAILED' in disk_info['health_status']: + health['status'] = 'FAILED' + health['issues'].append('SMART health test failed') + health['recommendations'].append('Backup data immediately and replace disk') + + # Check bad sectors + bad_sectors = disk_info.get('bad_sectors', 0) + pending = disk_info.get('pending_sectors', 0) + uncorrectable = disk_info.get('uncorrectable_sectors', 0) + + if bad_sectors > 0: + health['warnings'].append(f'Reallocated sectors: {bad_sectors}') + if bad_sectors > 100: + health['issues'].append(f'High number of reallocated sectors: {bad_sectors}') + health['recommendations'].append('Consider replacing disk soon') + + if pending > 0: + health['warnings'].append(f'Pending sectors: {pending}') + health['recommendations'].append('Run full disk surface test') + + if uncorrectable > 0: + health['issues'].append(f'Uncorrectable sectors: {uncorrectable}') + health['recommendations'].append('Disk may have physical damage, replace immediately') + + # Check temperature + temp = disk_info.get('temperature') + if temp: + if temp > 70: + health['warnings'].append(f'High temperature: {temp}°C') + health['recommendations'].append('Improve cooling') + elif temp > 60: + health['warnings'].append(f'Elevated temperature: {temp}°C') + + # Check power on hours (for HDDs) + if disk_info.get('type') == 'HDD' and disk_info.get('power_on_hours'): + hours = disk_info['power_on_hours'] + years = hours / 8760 # 365 * 24 + if years > 5: + health['warnings'].append(f'Disk age: {years:.1f} years of power-on time') + health['recommendations'].append('Consider replacing due to age') + + if not health['issues'] and not health['warnings']: + health['status'] = 'EXCELLENT' + elif not health['issues']: + health['status'] = 'WARNING' + + health_summary[disk_name] = health + + return health_summary + + @staticmethod + def _check_command_exists(command: str) -> bool: + """Check if a command exists in the system.""" + try: + subprocess.run( + ['which', command], + capture_output=True, + text=True, + timeout=5 + ) + return True + except Exception: + return False + + @staticmethod + def _bytes_to_human(bytes_val: Optional[int]) -> str: + """Convert bytes to human-readable format.""" + if bytes_val is None: + return "Unknown" + + for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']: + if bytes_val < 1024.0: + return f"{bytes_val:.2f} {unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.2f} EB" + + def refresh(self) -> 'DiskInfo': + """Clear cache and refresh disk information.""" + self._info_cache = {} + return self + + def __repr__(self) -> str: + if self.disk_name: + return f"DiskInfo(disk={self.disk_name})" + else: + disks = self.get_all_disks() + return f"DiskInfo(disks={disks})" + + +# ------------------------------------------------------------------ +# Example usage and test +# ------------------------------------------------------------------ +if __name__ == '__main__': + disk = DiskInfo() + + print("=== All Disks ===") + all_disks = disk.get_all_disks() + print(f"Found disks: {', '.join(all_disks)}") + print() + + # Get summary for all disks + print(disk.get_disk_summary()) + print() + + # Get detailed information for each disk + for disk_name in all_disks: + print(f"\n{'=' * 80}") + print(f"Detailed information for {disk_name}:") + print(f"{'=' * 80}") + + info = disk.get_disk_info(disk_name) + if info: + print(f"\nJSON-like structure (partial):") + for key, value in info.items(): + if key != 'partitions' and value is not None: + print(f" {key}: {value}") + + if info.get('partitions'): + print(f" partitions: {len(info['partitions'])} partitions found") + + # Check health for all disks + print(f"\n{'=' * 80}") + print("Disk Health Check") + print(f"{'=' * 80}") + health = disk.check_disk_health() + for disk_name, status in health.items(): + print(f"\n{disk_name}:") + print(f" Status: {status['status']}") + if status['warnings']: + print(f" Warnings: {', '.join(status['warnings'])}") + if status['issues']: + print(f" Issues: {', '.join(status['issues'])}") + if status['recommendations']: + print(f" Recommendations: {', '.join(status['recommendations'])}") + + # Example of setting a specific disk + if all_disks: + print(f"\n{'=' * 80}") + print(f"Working with specific disk: {all_disks[0]}") + print(f"{'=' * 80}") + disk.set_disk(all_disks[0]) + print(f"Disk model: {disk.get_disk_info().get('model', 'Unknown')}") + print(f"Disk size: {disk.get_disk_info().get('size_human', 'Unknown')}") \ No newline at end of file diff --git a/lib/sunhpc/sunhpc/error.py b/lib/sunhpc/sunhpc/error.py new file mode 100644 index 0000000..f6b603b --- /dev/null +++ b/lib/sunhpc/sunhpc/error.py @@ -0,0 +1,84 @@ +from enum import Enum +from typing import Dict, Any, Union, Optional + +class ErrorCode(Enum): + '''错误码枚举''' + + # status code + SUCCESS = '000' + FAIL = '001' + + # command error + INVALID_COMMAND = '010' + INVALID_FORMAT = '012' + + # config error + EMPTY_CONFIG = '020' + + # File error + FILE_NOT_FOUND_ERROR = '030' + + # module error + IMPORT_ERROR = '040' + + # network error + NETWORK_ERROR = '050' + + # timeout error + TIMEOUT_ERROR = '100' + + # yaml error + YAML_PARSE_ERROR = '402' + + @classmethod + def get_all_codes(cls): + return [code.value for code in cls] + +class ErrorMessages: + '''错误消息枚举''' + _MESSAGES = { + ErrorCode.SUCCESS: 'success', + ErrorCode.FAIL: 'fail', + ErrorCode.INVALID_COMMAND: 'invalid command', + ErrorCode.EMPTY_CONFIG: 'empty config', + ErrorCode.INVALID_FORMAT: 'invalid format', + ErrorCode.FILE_NOT_FOUND_ERROR: 'file not found', + ErrorCode.IMPORT_ERROR: 'import error', + ErrorCode.NETWORK_ERROR: 'network error', + ErrorCode.TIMEOUT_ERROR: 'timeout error', + ErrorCode.YAML_PARSE_ERROR: 'yaml parse error', + } + + @classmethod + def get_message(cls, err_code: Union[ErrorCode, str]) -> str: + if isinstance(err_code, str): + try: + error_code = ErrorCode(err_code) + except ValueError: + return f'unknown error code: {err_code}' + + return cls._MESSAGES.get(error_code, f'unknown error code: {err_code}') + +SUCCESS = ErrorCode.SUCCESS.value +FAIL = ErrorCode.FAIL.value +INVALID_COMMAND = ErrorCode.INVALID_COMMAND.value +INVALID_FORMAT = ErrorCode.INVALID_FORMAT.value +EMPTY_CONFIG = ErrorCode.EMPTY_CONFIG.value +FILE_NOT_FOUND_ERROR = ErrorCode.FILE_NOT_FOUND_ERROR.value +IMPORT_ERROR = ErrorCode.IMPORT_ERROR.value +NETWORK_ERROR = ErrorCode.NETWORK_ERROR.value +TIMEOUT_ERROR = ErrorCode.TIMEOUT_ERROR.value +YAML_PARSE_ERROR = ErrorCode.YAML_PARSE_ERROR.value + +def is_error(data: Any) -> bool: + ''' + 判断数据是否为错误码 + : param data: 待判断数据 + : return: True表示错误码,False表示正常码 + ''' + if not isinstance(data, str): + return False + + if len(data) == 3 and data.isdigit(): + return data in [ c.value for c in ErrorCode if c != ErrorCode.SUCCESS] + return False \ No newline at end of file diff --git a/lib/sunhpc/sunhpc/logs.py b/lib/sunhpc/sunhpc/logs.py new file mode 100644 index 0000000..c35f89f --- /dev/null +++ b/lib/sunhpc/sunhpc/logs.py @@ -0,0 +1,528 @@ +from re import I +import sys +import time + +from datetime import datetime +from typing import Optional, Any, Dict +from sunhpc.util import getTerminalWidth + +class Colors: + """终端颜色代码""" + # 基本颜色 + BLACK = '\033[30m' + RED = '\033[31m' + GREEN = '\033[32m' + YELLOW = '\033[33m' + BLUE = '\033[34m' + MAGENTA = '\033[35m' + CYAN = '\033[36m' + WHITE = '\033[37m' + + # 亮色 + BRIGHT_BLACK = '\033[90m' + BRIGHT_RED = '\033[91m' + BRIGHT_GREEN = '\033[92m' + BRIGHT_YELLOW = '\033[93m' + BRIGHT_BLUE = '\033[94m' + BRIGHT_MAGENTA = '\033[95m' + BRIGHT_CYAN = '\033[96m' + BRIGHT_WHITE = '\033[97m' + + # 背景色 + BG_RED = '\033[41m' + BG_GREEN = '\033[42m' + BG_YELLOW = '\033[43m' + BG_BLUE = '\033[44m' + BG_MAGENTA = '\033[45m' + BG_CYAN = '\033[46m' + BG_WHITE = '\033[47m' + + # 样式 + BOLD = '\033[1m' + DIM = '\033[2m' + ITALIC = '\033[3m' + UNDERLINE = '\033[4m' + BLINK = '\033[5m' + REVERSE = '\033[7m' + HIDDEN = '\033[8m' + + # 重置 + RESET = '\033[0m' + +class LogLevel: + """日志级别定义""" + DEBUG = 0 + INFO = 1 + OK = 2 + WARN = 3 + ERROR = 4 + CRITICAL = 5 + + _names = { + DEBUG: "DEBUG", + INFO: "INFO", + OK: "OK", + WARN: "WARN", + ERROR: "ERROR", + CRITICAL: "CRITICAL" + } + + _prefixes = { + DEBUG: "[d]", + INFO: "[i]", + OK: "[+]", + WARN: "[-]", + ERROR: "[e]", + CRITICAL: "[!]" + } + + _colors = { + DEBUG: Colors.BRIGHT_BLACK, + INFO: Colors.BRIGHT_BLUE, + OK: Colors.BRIGHT_GREEN, + WARN: Colors.BRIGHT_YELLOW, + ERROR: Colors.BRIGHT_RED, + CRITICAL: Colors.BG_RED + Colors.BRIGHT_WHITE + Colors.BOLD + } + + @classmethod + def get_name(cls, level: int) -> str: + return cls._names.get(level, "UNKNOWN") + + @classmethod + def get_prefix(cls, level: int) -> str: + return cls._prefixes.get(level, "[?]") + + @classmethod + def get_color(cls, level: int) -> str: + return cls._colors.get(level, Colors.WHITE) + + def __str__(self): + return self.get_name(self.level) + +class Logs: + """日志类""" + + def __init__(self, + name: str = "", + level: int = LogLevel.INFO, + show_time: bool = True, + show_level: bool = False, + show_caller: bool = False, + show_function: bool = False, + time_format: str = "%H:%M:%S", + output_file: Optional[str] = None, + color_enabled: bool = True): + """ + 初始化日志对象 + + :param name: 日志名称(通常为模块名) + :param level: 最低日志级别 + :param show_time: 是否显示时间 + :param show_level: 是否显示日志级别名称 + :param time_format: 时间格式 + :param color_enabled: 是否启用颜色 + """ + self.name = name + self.level = level + self.reduce = 20 + self.show_time = show_time + self.show_level = show_level + self.output_file = output_file + self.show_caller = show_caller + self.show_function = show_function + self.time_format = time_format + self.color_enabled = color_enabled and self._supports_color() + + self._file_handler = None + if self.output_file: + try: + self._file_handler = open(self.output_file, 'a', encoding='utf-8') + except Exception: + print(f"Failed to open log file {self.output_file}: {e}", file=sys.stderr) + + def __del__(self): + if self._file_handler: + self._file_handler.close() + + def _get_caller_info(self, stack_level: int = 3) -> Dict[str, str]: + """ + Get caller information from the stack. + + Args: + stack_level: How many levels to go up the stack (2 = direct caller) + + Returns: + Dictionary with file, line, function information + """ + try: + # Get the current stack frame + frame = sys._getframe(stack_level) + + # Get caller information + filename = frame.f_code.co_filename + line_number = frame.f_lineno + function_name = frame.f_code.co_name + + # Get relative path (remove current working directory) + try: + cwd = os.getcwd() + if filename.startswith(cwd): + filename = os.path.relpath(filename, cwd) + except Exception: + pass + + return { + 'file': filename, + 'line': str(line_number), + 'function': function_name, + 'full_path': filename + } + except Exception: + return { + 'file': 'unknown', + 'line': '?', + 'function': 'unknown', + 'full_path': 'unknown' + } + + + def _supports_color(self) -> bool: + """检测终端是否支持颜色""" + return ( + hasattr(sys.stdout, 'isatty') and sys.stdout.isatty() + ) or 'ANSICON' in sys.environ or 'PYCHARM_HOSTED' in sys.environ + + def _format_message(self, + msg: str, + level: int, + same_color: Optional[str] = None, + caller_info: Optional[Dict[str, str]] = None) -> str: + """ + 格式化日志消息 + + :param msg: 日志消息 + :param level: 日志级别 + :param same_color: 统一颜色(如果指定,则忽略级别颜色) + :param caller_info: 调用者信息(文件、行号、函数名、完整路径,可选) + :return: 格式化后的消息 + """ + parts = [] + + # 时间戳 + if self.show_time: + timestamp = datetime.now().strftime(self.time_format) + if self.color_enabled: + parts.append(f"{Colors.BRIGHT_BLACK}{timestamp}{Colors.RESET}") + else: + parts.append(timestamp) + + # 日志名称 + if self.name: + if self.color_enabled: + parts.append(f"{Colors.BRIGHT_CYAN}[{self.name}]{Colors.RESET}") + else: + parts.append(f"[{self.name}]") + + # 日志级别名称 + if self.show_level: + level_name = LogLevel.get_name(level) + if self.color_enabled: + color = same_color if same_color else LogLevel.get_color(level) + parts.append(f"{color}{level_name:7}{Colors.RESET}") + else: + parts.append(f"{level_name:7}") + + # 调用者信息 + if self.show_caller and caller_info: + parts.append(f"{caller_info['file']}:{caller_info['line']}") + + # 日志级别前缀 + prefix = LogLevel.get_prefix(level) + if self.color_enabled: + color = same_color if same_color else LogLevel.get_color(level) + prefix_colored = f"{color}{prefix}{Colors.RESET}" + parts.append(prefix_colored) + else: + parts.append(prefix) + + # 消息内容 + if self.color_enabled and same_color: + parts.append(f"{same_color}{msg}{Colors.RESET}") + elif self.color_enabled: + color = LogLevel.get_color(level) + parts.append(f"{color}{msg}{Colors.RESET}") + else: + parts.append(msg) + + return " ".join(parts) + + def _split_words(self, msg: str) -> list: + """将消息按宽度拆分成多个行""" + try: + terminal_width = (getTerminalWidth() - 30) + except: + terminal_width = 80 + + if type(msg) in [type([]), type(())]: + msg = ' '.join(msg) + + msg = ' '.join(msg.strip('\n').splitlines()) + if len(msg) <= terminal_width: + return [msg] + + words = msg.split() + result = [] + current_line = '' + for word in words: + if current_line: + test_line = current_line + " " + word + else: + test_line = word + + if len(test_line) <= terminal_width: + current_line = test_line + else: + if current_line: + result.append(current_line) + + if len(word) > terminal_width: + for i in range(0, len(word), terminal_width): + result.append(word[i:i+terminal_width]) + current_line = '' + else: + current_line = word + + if current_line: + result.append(current_line) + + return result + + def _log(self, level: int, msg: str, same_color: Optional[str] = None): + """内部日志方法""" + if level < self.level: + return + + # Get caller information (skip _log and the calling method) + caller_info = self._get_caller_info() + intput_lines = self._split_words(msg) + output_lines = [] + for line in intput_lines: + output_lines.append(self._format_message(line, level, same_color, caller_info)) + + # 写入文件 + if self._file_handler: + try: + self._file_handler.write(formatted_msg + '\n') + self._file_handler.flush() + except Exception: + pass + + # 错误级别输出到 stderr + if level >= LogLevel.ERROR: + for line in output_lines: + if line.strip(): + print (line, file=sys.stderr) + #print(formatted_msg, file=sys.stderr) + else: + for line in output_lines: + if line.strip(): + print (line) + + def set_level(self, level: int): + """设置日志级别""" + self.level = level + + def set_output_file(self, filename: str) -> None: + '''Set or change the output file.''' + if self._file_handler: + self._file_handler.close() + try: + self._file_handler = open(filename, 'a', encoding='utf-8') + self.output_file = filename + except Exception: + pass + + def debug(self, msg: str, same_color: Optional[str] = None): + """调试日志 [d]""" + self._log(LogLevel.DEBUG, msg, same_color) + + def info(self, msg: str, same_color: Optional[str] = None): + """信息日志 [i]""" + self._log(LogLevel.INFO, msg, same_color) + + def ok(self, msg: str, same_color: Optional[str] = None): + """成功日志 [+]""" + self._log(LogLevel.OK, msg, same_color) + + def warn(self, msg: str, same_color: Optional[str] = None): + """警告日志 [-]""" + self._log(LogLevel.WARN, msg, same_color) + + def error(self, msg: str, same_color: Optional[str] = None): + """错误日志 [e]""" + self._log(LogLevel.ERROR, msg, same_color) + + def critical(self, msg: str, same_color: Optional[str] = None): + """严重错误日志 [!]""" + self._log(LogLevel.CRITICAL, msg, same_color) + + def __call__(self, msg: str, level: str = "info", same_color: Optional[str] = None): + """ + 直接调用方法,支持字符串级别 + + :param msg: 日志消息 + :param level: 日志级别 (debug, info, ok, warn, error, critical) + :param same_color: 统一颜色 + """ + level_map = { + "debug": LogLevel.DEBUG, + "info": LogLevel.INFO, + "ok": LogLevel.OK, + "warn": LogLevel.WARN, + "warning": LogLevel.WARN, + "error": LogLevel.ERROR, + "critical": LogLevel.CRITICAL, + "err": LogLevel.ERROR, + } + + level_value = level_map.get(level.lower(), LogLevel.INFO) + + # 转换颜色字符串到颜色代码(如果提供) + color_code = None + if same_color and self.color_enabled: + color_code = self._get_color_code(same_color) + + self._log(level_value, msg, color_code) + + def _get_color_code(self, color_name: str) -> str: + """根据颜色名称获取颜色代码""" + color_map = { + "black": Colors.BLACK, + "red": Colors.RED, + "green": Colors.GREEN, + "yellow": Colors.YELLOW, + "blue": Colors.BLUE, + "magenta": Colors.MAGENTA, + "cyan": Colors.CYAN, + "white": Colors.WHITE, + "bright_black": Colors.BRIGHT_BLACK, + "bright_red": Colors.BRIGHT_RED, + "bright_green": Colors.BRIGHT_GREEN, + "bright_yellow": Colors.BRIGHT_YELLOW, + "bright_blue": Colors.BRIGHT_BLUE, + "bright_magenta": Colors.BRIGHT_MAGENTA, + "bright_cyan": Colors.BRIGHT_CYAN, + "bright_white": Colors.BRIGHT_WHITE, + } + return color_map.get(color_name.lower(), Colors.WHITE) + + def line(self, char: str = "-", length: Optional[int] = None): + """打印分隔线""" + if length is None: + try: + import shutil + length = shutil.get_terminal_size().columns + except: + length = 80 + + line_str = char * min(length, 100) # 限制最大长度 + if self.color_enabled: + print(f"{Colors.BRIGHT_BLACK}{line_str}{Colors.RESET}") + else: + print(line_str) + + def blank(self): + """打印空行""" + print() + + +# 创建默认日志实例 +default_logger = Logs() + +# 为了方便使用,提供模块级别的函数 +def debug(msg: str): + default_logger.debug(msg) + +def info(msg: str): + default_logger.info(msg) + +def ok(msg: str): + default_logger.ok(msg) + +def warn(msg: str): + default_logger.warn(msg) + +def error(msg: str): + default_logger.error(msg) + +def critical(msg: str): + default_logger.critical(msg) + + +# 使用示例 +if __name__ == "__main__": + # 示例1:基本使用 + print("=" * 60) + print("示例1:基本使用") + print("=" * 60) + + log = Logs(name="Test", show_time=True, show_level=True) + log.ok("操作成功完成") + log.info("正在加载配置...") + log.warn("配置文件未找到,使用默认配置") + log.error("连接数据库失败") + log.debug("这是调试信息") # 默认 INFO 级别,不会显示 + + print("\n") + + # 示例2:使用 __call__ 方法(self.log('msg', 'err')) + print("=" * 60) + print("示例2:使用 __call__ 方法") + print("=" * 60) + + log2 = Logs(name="MyApp", show_time=False) + log2("这是一条普通信息", "info") + log2("这是一条警告", "warn") + log2("这是一条错误", "err") + log2("这是一条成功消息", "ok") + log2("这是调试信息", "debug") + + print("\n") + + # 示例3:固定颜色 + print("=" * 60) + print("示例3:固定颜色输出") + print("=" * 60) + + log3 = Logs(name="ColorTest") + log3("这条消息是红色的", "error", "red") + log3("这条消息是绿色的", "info", "green") + log3("这条消息是黄色的", "warn", "yellow") + log3("这条消息是蓝色的", "ok", "blue") + + print("\n") + + # 示例4:不使用颜色 + print("=" * 60) + print("示例4:禁用颜色") + print("=" * 60) + + log4 = Logs(name="NoColor", color_enabled=False) + log4.ok("没有颜色的成功消息") + log4.error("没有颜色的错误消息") + + print("\n") + + # 示例5:分隔线 + print("=" * 60) + print("示例5: 工具方法") + print("=" * 60) + + log5 = Logs(name="Tool") + log5.line("=") + log5.ok("功能演示") + log5.line("-") + log5.blank() + log5.info("完成") + log5.line("=") \ No newline at end of file diff --git a/lib/sunhpc/sunhpc/main.py b/lib/sunhpc/sunhpc/main.py index bd9905a..4401415 100644 --- a/lib/sunhpc/sunhpc/main.py +++ b/lib/sunhpc/sunhpc/main.py @@ -6,9 +6,16 @@ import sunhpc import signal import syslog import sunhpc.util +import sunhpc.error as error + +from sunhpc.util import CommandError + +from sunhpc.logs import Logs +slog = Logs(name="Sunhpc", show_time=True) + +from sunhpc.util import getTerminalWidth def _main(argv=None): - try: database = None except ImportError: @@ -46,7 +53,7 @@ def _main(argv=None): print ('error - invalid sunhpc command "%s"' % args[0]) sys.exit(-1) - name = ' '.join('.'.split(s)) + name = ' '.join(s.split('.')[2:]) try: command = getattr(module, 'Command')(database) @@ -63,36 +70,51 @@ def _main(argv=None): command.isRootUser() or command.isApacheUser()): os.system('sudo %s' % ' '.join(sys.argv)) else: + #import sunhpc try: command.runWrapper(name, args[i:]) text = command.getText() if len(text) > 0: - print (text, end=' ') - if text[len(text)-1] != '\n': - print () - except sunhpc.util.CommandError as e: + if text.endswith('\n'): + sys.stdout.write(text) + else: + print (text) + print () + except CommandError as e: print ('Error: ', e) print (command.usage()) - exit(1) - - syslog.closelog() + return 1 + config_file = '/etc/sunhpc/sunhpc.yaml' + if not os.path.exists(config_file): + slog.error('=' * (getTerminalWidth() - 30)) + slog.error ('config file "%s" not found' % config_file) + slog.info('Run following command to create the config file:') + slog.info(' # sunhpc init config eth1') + slog.error('=' * (getTerminalWidth() - 30)) + syslog.closelog() + return 0 def main(argv=None): - _main(argv) - ''' - try: - return _main(argv) - except KeyboardInterrupt: - sys.stderr.write("\n") - tty.error("Keyboard interrupt.") - return signal.SIGINT.value + if any(flag in argv for flag in ['--debug', '-d']): + _main(argv) + else: + try: + return _main(argv) + except KeyboardInterrupt: + sys.stderr.write("\n") + tty.error("Keyboard interrupt.") + return signal.SIGINT.value - except SystemExit as e: - return e.code + except AttributeError: + sys.stderr.write("\n") + tty.error("Attribute error.") + return 1 - except Exception as e: - tty.error(e) - return 3 - ''' + except SystemExit as e: + return e.code + + except Exception as e: + tty.error(e) + return 3 diff --git a/lib/sunhpc/sunhpc/memory.py b/lib/sunhpc/sunhpc/memory.py new file mode 100644 index 0000000..672cbdc --- /dev/null +++ b/lib/sunhpc/sunhpc/memory.py @@ -0,0 +1,880 @@ +""" +MemoryInfo class to retrieve comprehensive system memory information. +Supports physical RAM, swap memory, and virtualization detection. +No third-party libraries required, uses /proc, /sys, and system commands. +""" + +import os +import re +import glob +from typing import Optional, Dict, List, Any, Tuple + + +class MemoryInfo: + """Class to retrieve detailed information about system memory.""" + + def __init__(self): + self._info_cache: Dict[str, Any] = {} + + def get_memory_info(self, refresh: bool = False) -> Dict[str, Any]: + """ + Get comprehensive memory information. + Returns dictionary with all available memory information. + """ + if not refresh and self._info_cache: + return self._info_cache + + info = { + 'physical_memory': self._get_physical_memory_info(), + 'swap_memory': self._get_swap_info(), + 'virtual_memory': self._get_virtual_memory_stats(), + 'memory_modules': self._get_memory_modules_info(), + 'top_memory_processes': self._get_top_memory_processes(), + 'memory_stats': self._get_memory_statistics(), + 'numa_info': self._get_numa_info(), + 'is_virtual': False, + 'virtual_type': None + } + + # Detect virtualization + self._detect_virtualization(info) + + self._info_cache = info + return info + + def _get_physical_memory_info(self) -> Dict[str, Any]: + """Get physical memory information from /proc/meminfo and /sys.""" + mem_info = { + 'total_bytes': None, + 'total_human': None, + 'available_bytes': None, + 'available_human': None, + 'free_bytes': None, + 'free_human': None, + 'used_bytes': None, + 'used_human': None, + 'used_percent': None, + 'buffers_bytes': None, + 'cached_bytes': None, + 'shared_bytes': None, + 'slab_bytes': None, + 'hugepages_total': None, + 'hugepages_free': None, + 'hugepages_size_bytes': None, + 'hugepages_size_human': None + } + + # Parse /proc/meminfo + try: + with open('/proc/meminfo', 'r') as f: + mem_data = f.read() + + # Extract values + patterns = { + 'total_bytes': r'MemTotal:\s+(\d+)\s+kB', + 'available_bytes': r'MemAvailable:\s+(\d+)\s+kB', + 'free_bytes': r'MemFree:\s+(\d+)\s+kB', + 'buffers_bytes': r'Buffers:\s+(\d+)\s+kB', + 'cached_bytes': r'Cached:\s+(\d+)\s+kB', + 'shared_bytes': r'Shmem:\s+(\d+)\s+kB', + 'slab_bytes': r'Slab:\s+(\d+)\s+kB', + 'hugepages_total': r'HugePages_Total:\s+(\d+)', + 'hugepages_free': r'HugePages_Free:\s+(\d+)', + 'hugepages_size_bytes': r'Hugepagesize:\s+(\d+)\s+kB' + } + + for key, pattern in patterns.items(): + match = re.search(pattern, mem_data, re.MULTILINE) + if match: + value = int(match.group(1)) + if key.endswith('_bytes'): + # Convert KB to bytes + value *= 1024 + mem_info[key] = value + + # Calculate used memory + if mem_info['total_bytes'] and mem_info['available_bytes']: + mem_info['used_bytes'] = mem_info['total_bytes'] - mem_info['available_bytes'] + mem_info['used_human'] = self._bytes_to_human(mem_info['used_bytes']) + mem_info['used_percent'] = (mem_info['used_bytes'] / mem_info['total_bytes']) * 100 + + # Convert bytes to human readable + for key in ['total_bytes', 'available_bytes', 'free_bytes', 'buffers_bytes', + 'cached_bytes', 'shared_bytes', 'slab_bytes']: + if mem_info.get(key): + human_key = key.replace('_bytes', '_human') + mem_info[human_key] = self._bytes_to_human(mem_info[key]) + + if mem_info.get('hugepages_size_bytes'): + mem_info['hugepages_size_human'] = self._bytes_to_human(mem_info['hugepages_size_bytes']) + + except Exception as e: + pass + + return mem_info + + def _get_swap_info(self) -> Dict[str, Any]: + """Get swap memory information.""" + swap_info = { + 'total_bytes': None, + 'total_human': None, + 'free_bytes': None, + 'free_human': None, + 'used_bytes': None, + 'used_human': None, + 'used_percent': None, + 'swap_devices': [] + } + + # Parse /proc/meminfo for swap totals + try: + with open('/proc/meminfo', 'r') as f: + mem_data = f.read() + + total_match = re.search(r'SwapTotal:\s+(\d+)\s+kB', mem_data) + free_match = re.search(r'SwapFree:\s+(\d+)\s+kB', mem_data) + + if total_match: + swap_info['total_bytes'] = int(total_match.group(1)) * 1024 + swap_info['total_human'] = self._bytes_to_human(swap_info['total_bytes']) + + if free_match: + swap_info['free_bytes'] = int(free_match.group(1)) * 1024 + swap_info['free_human'] = self._bytes_to_human(swap_info['free_bytes']) + + if swap_info['total_bytes'] and swap_info['free_bytes']: + swap_info['used_bytes'] = swap_info['total_bytes'] - swap_info['free_bytes'] + swap_info['used_human'] = self._bytes_to_human(swap_info['used_bytes']) + if swap_info['total_bytes'] > 0: + swap_info['used_percent'] = (swap_info['used_bytes'] / swap_info['total_bytes']) * 100 + + except Exception: + pass + + # Get individual swap devices from /proc/swaps + try: + with open('/proc/swaps', 'r') as f: + lines = f.readlines()[1:] # Skip header + for line in lines: + parts = line.strip().split() + if len(parts) >= 4: + swap_dev = { + 'device': parts[0], + 'type': parts[1], + 'size_bytes': int(parts[2]) * 1024, + 'size_human': self._bytes_to_human(int(parts[2]) * 1024), + 'used_bytes': int(parts[3]) * 1024, + 'used_human': self._bytes_to_human(int(parts[3]) * 1024), + 'priority': int(parts[4]) if len(parts) > 4 else None + } + swap_info['swap_devices'].append(swap_dev) + except Exception: + pass + + return swap_info + + def _get_virtual_memory_stats(self) -> Dict[str, Any]: + """Get virtual memory statistics from /proc/vmstat.""" + vm_stats = { + 'pgpgin': None, # Pages paged in + 'pgpgout': None, # Pages paged out + 'pswpin': None, # Pages swapped in + 'pswpout': None, # Pages swapped out + 'pgfault': None, # Page faults + 'pgmajfault': None, # Major page faults + 'pgfree': None, # Pages freed + 'pgactivate': None, # Pages activated + 'pgdeactivate': None,# Pages deactivated + 'pglaundry': None, # Pages laundried + 'pgrefill': None, # Pages refilled + 'pgsteal': None, # Pages stolen + 'pgscan_kswapd': None, # Pages scanned by kswapd + 'pgscan_direct': None, # Pages scanned directly + 'pgscan_khugepaged': None, # Pages scanned by khugepaged + } + + try: + with open('/proc/vmstat', 'r') as f: + for line in f: + parts = line.strip().split() + if len(parts) == 2: + key, value = parts + if key in vm_stats: + vm_stats[key] = int(value) + except Exception: + pass + + return vm_stats + + def _get_memory_modules_info(self) -> List[Dict[str, Any]]: + """ + Get physical memory module information from DMIDECODE or /sys. + Requires root for full details. + """ + modules = [] + + # Try to get from /sys/devices/system/edac/mc/ + try: + mc_paths = glob.glob('/sys/devices/system/edac/mc/mc*') + for mc_path in mc_paths: + dimm_paths = glob.glob(f'{mc_path}/dimm/dimm*') + for dimm_path in dimm_paths: + module = self._read_dimm_info(dimm_path) + if module: + modules.append(module) + except Exception: + pass + + # Try dmidecode for detailed module info (requires root) + if not modules: + modules = self._get_dmidecode_memory_info() + + # If no module info found, try to get from /proc/meminfo + if not modules: + modules = self._get_basic_memory_info() + + return modules + + def _read_dimm_info(self, dimm_path: str) -> Optional[Dict[str, Any]]: + """Read DIMM information from sysfs.""" + module = {} + + try: + # Try to read various attributes + attrs = { + 'size': 'size', + 'manufacturer': 'manufacturer', + 'part_number': 'part_num', + 'serial_number': 'serial', + 'rank': 'rank' + } + + for attr_file, attr_name in attrs.items(): + file_path = f'{dimm_path}/{attr_file}' + if os.path.exists(file_path): + with open(file_path, 'r') as f: + value = f.read().strip() + if value and not value.isspace(): + module[attr_name] = value + + # Parse size (usually in MB) + if 'size' in module: + try: + size_mb = int(module['size']) + module['size_bytes'] = size_mb * 1024 * 1024 + module['size_human'] = self._bytes_to_human(module['size_bytes']) + except ValueError: + pass + + if module: + module['type'] = 'Unknown' + return module + except Exception: + pass + + return None + + def _get_dmidecode_memory_info(self) -> List[Dict[str, Any]]: + """Get detailed memory module info using dmidecode command.""" + modules = [] + + if not self._check_command_exists('dmidecode'): + return modules + + try: + # Run dmidecode to get memory information + result = self._run_command(['dmidecode', '-t', 'memory'], timeout=10) + if not result: + return modules + + # Parse dmidecode output + current_module = {} + in_memory_device = False + + for line in result.split('\n'): + line = line.strip() + + if 'Memory Device' in line: + if current_module and in_memory_device: + modules.append(current_module) + current_module = {} + in_memory_device = True + + elif in_memory_device and ':' in line: + key, value = line.split(':', 1) + key = key.strip().lower().replace(' ', '_') + value = value.strip() + + if key == 'size': + if value != 'No Module Installed' and 'MB' in value: + size_mb = int(value.split()[0]) + current_module['size_bytes'] = size_mb * 1024 * 1024 + current_module['size_human'] = self._bytes_to_human(current_module['size_bytes']) + current_module['size_mb'] = size_mb + + elif key == 'Locator': + current_module['locator'] = value + + elif key == 'type': + current_module['type'] = value + + elif key == 'speed': + current_module['speed'] = value + + elif key == 'manufacturer': + current_module['manufacturer'] = value + + elif key == 'serial_number': + current_module['serial'] = value + + elif key == 'asset_tag': + current_module['asset_tag'] = value + + elif key == 'part_number': + current_module['part_number'] = value + + elif key == 'rank': + current_module['rank'] = value + + elif key == 'configured_clock_speed': + current_module['configured_speed'] = value + + elif key == 'configured_memory_speed': + current_module['configured_memory_speed'] = value + + elif key == 'minimum_voltage': + current_module['minimum_voltage'] = value + + elif key == 'maximum_voltage': + current_module['maximum_voltage'] = value + + elif key == 'configured_voltage': + current_module['configured_voltage'] = value + + elif key == 'memory_technology': + current_module['memory_technology'] = value + + elif key == 'type_detail': + current_module['type_detail'] = value + + if current_module and in_memory_device: + modules.append(current_module) + + except Exception: + pass + + return modules + + def _get_basic_memory_info(self) -> List[Dict[str, Any]]: + """Get basic memory information when detailed info is not available.""" + modules = [] + + # Get total memory size + total_bytes = None + try: + with open('/proc/meminfo', 'r') as f: + for line in f: + if 'MemTotal' in line: + total_kb = int(re.search(r'(\d+)', line).group(1)) + total_bytes = total_kb * 1024 + break + except Exception: + pass + + if total_bytes: + module = { + 'size_bytes': total_bytes, + 'size_human': self._bytes_to_human(total_bytes), + 'type': 'Unknown', + 'manufacturer': 'Unknown', + 'serial': 'Unknown', + 'part_number': 'Unknown', + 'note': 'Detailed information not available (requires root or dmidecode)' + } + modules.append(module) + + return modules + + def _get_top_memory_processes(self, count: int = 10) -> List[Dict[str, Any]]: + """Get top memory-consuming processes.""" + processes = [] + + try: + # Read /proc for process information + for pid in os.listdir('/proc'): + if not pid.isdigit(): + continue + + try: + # Read process status + with open(f'/proc/{pid}/status', 'r') as f: + status = f.read() + + # Get process name + name_match = re.search(r'Name:\s+(.+?)\n', status) + if not name_match: + continue + name = name_match.group(1) + + # Get memory usage (VmRSS in kB) + vmrss_match = re.search(r'VmRSS:\s+(\d+)\s+kB', status) + if not vmrss_match: + continue + memory_kb = int(vmrss_match.group(1)) + + if memory_kb > 0: + processes.append({ + 'pid': int(pid), + 'name': name, + 'memory_kb': memory_kb, + 'memory_bytes': memory_kb * 1024, + 'memory_human': self._bytes_to_human(memory_kb * 1024) + }) + + except (IOError, OSError, ValueError): + continue + + except Exception: + pass + + # Sort by memory usage (descending) and return top N + processes.sort(key=lambda x: x['memory_kb'], reverse=True) + return processes[:count] + + def _get_memory_statistics(self) -> Dict[str, Any]: + """Get memory statistics and pressure information.""" + stats = { + 'load_average': None, + 'memory_pressure': None, + 'swap_pressure': None, + 'io_pressure': None, + 'oom_killer_enabled': None, + 'memory_fragmentation': None + } + + # Get load average + try: + with open('/proc/loadavg', 'r') as f: + load = f.read().strip().split() + if len(load) >= 3: + stats['load_average'] = { + '1min': float(load[0]), + '5min': float(load[1]), + '15min': float(load[2]) + } + except Exception: + pass + + # Check if OOM killer is enabled + try: + with open('/proc/sys/vm/oom_kill_allocating_task', 'r') as f: + stats['oom_killer_enabled'] = int(f.read().strip()) == 1 + except Exception: + pass + + # Memory pressure (if using cgroup v2) + try: + if os.path.exists('/proc/pressure/memory'): + with open('/proc/pressure/memory', 'r') as f: + content = f.read().strip() + stats['memory_pressure'] = self._parse_pressure_info(content) + + if os.path.exists('/proc/pressure/swap'): + with open('/proc/pressure/swap', 'r') as f: + content = f.read().strip() + stats['swap_pressure'] = self._parse_pressure_info(content) + + if os.path.exists('/proc/pressure/io'): + with open('/proc/pressure/io', 'r') as f: + content = f.read().strip() + stats['io_pressure'] = self._parse_pressure_info(content) + except Exception: + pass + + # Memory fragmentation info + try: + with open('/proc/buddyinfo', 'r') as f: + stats['memory_fragmentation'] = f.read().strip() + except Exception: + pass + + return stats + + def _get_numa_info(self) -> Optional[Dict[str, Any]]: + """Get NUMA (Non-Uniform Memory Access) information.""" + numa_info = { + 'numa_enabled': False, + 'numa_nodes': [], + 'numa_stats': {} + } + + # Check if NUMA is enabled + if not os.path.exists('/sys/devices/system/node'): + return None + + try: + # Get NUMA nodes + node_paths = glob.glob('/sys/devices/system/node/node*') + if node_paths: + numa_info['numa_enabled'] = True + + for node_path in node_paths: + node_id = os.path.basename(node_path).replace('node', '') + + node_info = { + 'node_id': int(node_id), + 'memory_bytes': None, + 'memory_human': None, + 'cpus': [] + } + + # Get node memory + meminfo_path = f'{node_path}/meminfo' + if os.path.exists(meminfo_path): + with open(meminfo_path, 'r') as f: + for line in f: + if 'MemTotal' in line: + mem_kb = int(re.search(r'(\d+)', line).group(1)) + node_info['memory_bytes'] = mem_kb * 1024 + node_info['memory_human'] = self._bytes_to_human(node_info['memory_bytes']) + break + + # Get CPUs in this node + cpulist_path = f'{node_path}/cpulist' + if os.path.exists(cpulist_path): + with open(cpulist_path, 'r') as f: + cpu_list = f.read().strip() + node_info['cpus'] = self._parse_cpu_list(cpu_list) + + numa_info['numa_nodes'].append(node_info) + + # Get NUMA statistics + if os.path.exists('/proc/zoneinfo'): + with open('/proc/zoneinfo', 'r') as f: + content = f.read() + # Parse node-specific stats + for node in numa_info['numa_nodes']: + node_id = node['node_id'] + pattern = rf'Node {node_id}.*?pages free\s+(\d+)' + match = re.search(pattern, content, re.DOTALL) + if match: + numa_info['numa_stats'][f'node_{node_id}_free_pages'] = int(match.group(1)) + + except Exception: + pass + + return numa_info + + def _detect_virtualization(self, info: Dict[str, Any]): + """Detect if system is virtualized.""" + # Check CPU info + try: + with open('/proc/cpuinfo', 'r') as f: + cpuinfo = f.read().lower() + if 'hypervisor' in cpuinfo: + info['is_virtual'] = True + if 'kvm' in cpuinfo: + info['virtual_type'] = 'KVM' + elif 'vmware' in cpuinfo: + info['virtual_type'] = 'VMware' + elif 'virtualbox' in cpuinfo: + info['virtual_type'] = 'VirtualBox' + elif 'xen' in cpuinfo: + info['virtual_type'] = 'Xen' + else: + info['virtual_type'] = 'Unknown' + except Exception: + pass + + # Check DMI info + if not info['is_virtual']: + try: + if os.path.exists('/sys/class/dmi/id/product_name'): + with open('/sys/class/dmi/id/product_name', 'r') as f: + product = f.read().strip().lower() + if 'vmware' in product: + info['is_virtual'] = True + info['virtual_type'] = 'VMware' + elif 'virtualbox' in product: + info['is_virtual'] = True + info['virtual_type'] = 'VirtualBox' + elif 'kvm' in product or 'qemu' in product: + info['is_virtual'] = True + info['virtual_type'] = 'KVM/QEMU' + except Exception: + pass + + @staticmethod + def _parse_pressure_info(content: str) -> Dict[str, Any]: + """Parse pressure stall information.""" + pressure = {} + for line in content.split('\n'): + if line.startswith('some'): + parts = line.split() + pressure['some'] = { + 'avg10': float(parts[1].split('=')[1]), + 'avg60': float(parts[2].split('=')[1]), + 'avg300': float(parts[3].split('=')[1]), + 'total': int(parts[4].split('=')[1]) + } + elif line.startswith('full'): + parts = line.split() + pressure['full'] = { + 'avg10': float(parts[1].split('=')[1]), + 'avg60': float(parts[2].split('=')[1]), + 'avg300': float(parts[3].split('=')[1]), + 'total': int(parts[4].split('=')[1]) + } + return pressure + + @staticmethod + def _parse_cpu_list(cpu_list: str) -> List[int]: + """Parse CPU list string (e.g., '0-3,5,7-9') into list of CPU numbers.""" + cpus = [] + for part in cpu_list.split(','): + if '-' in part: + start, end = map(int, part.split('-')) + cpus.extend(range(start, end + 1)) + else: + cpus.append(int(part)) + return cpus + + def get_memory_summary(self) -> str: + """Get a human-readable summary of memory information.""" + info = self.get_memory_info() + + lines = [] + lines.append("=" * 80) + lines.append("MEMORY INFORMATION SUMMARY") + lines.append("=" * 80) + + # Physical Memory + phys = info['physical_memory'] + lines.append("\nPhysical Memory:") + lines.append(f" Total: {phys.get('total_human', 'Unknown')}") + lines.append(f" Used: {phys.get('used_human', 'Unknown')} ({phys.get('used_percent', 0):.1f}%)") + lines.append(f" Available: {phys.get('available_human', 'Unknown')}") + lines.append(f" Free: {phys.get('free_human', 'Unknown')}") + lines.append(f" Buffers: {phys.get('buffers_human', 'Unknown')}") + lines.append(f" Cached: {phys.get('cached_human', 'Unknown')}") + + if phys.get('hugepages_total', 0) > 0: + lines.append(f" HugePages: {phys['hugepages_total']} total, {phys['hugepages_free']} free") + lines.append(f" HugePage Size: {phys.get('hugepages_size_human', 'Unknown')}") + + # Swap Memory + swap = info['swap_memory'] + if swap.get('total_bytes', 0) > 0: + lines.append("\nSwap Memory:") + lines.append(f" Total: {swap.get('total_human', 'Unknown')}") + lines.append(f" Used: {swap.get('used_human', 'Unknown')} ({swap.get('used_percent', 0):.1f}%)") + lines.append(f" Free: {swap.get('free_human', 'Unknown')}") + + if swap.get('swap_devices'): + lines.append(" Swap Devices:") + for dev in swap['swap_devices']: + lines.append(f" - {dev['device']} ({dev['type']}): {dev['size_human']}, " + f"used: {dev['used_human']}") + + # Memory Modules + modules = info['memory_modules'] + if modules: + lines.append(f"\nMemory Modules ({len(modules)}):") + for i, module in enumerate(modules, 1): + lines.append(f" Module {i}:") + if 'size_human' in module: + lines.append(f" Size: {module['size_human']}") + if 'type' in module: + lines.append(f" Type: {module['type']}") + if 'speed' in module: + lines.append(f" Speed: {module['speed']}") + if 'manufacturer' in module and module['manufacturer'] != 'Unknown': + lines.append(f" Manufacturer: {module['manufacturer']}") + if 'part_number' in module and module['part_number'] != 'Unknown': + lines.append(f" Part Number: {module['part_number']}") + if 'serial' in module and module['serial'] != 'Unknown': + lines.append(f" Serial: {module['serial']}") + if 'note' in module: + lines.append(f" Note: {module['note']}") + if 'configured_voltage' in module: + lines.append(f" Configured Voltage: {module['configured_voltage']}") + if 'memory_technology' in module: + lines.append(f" Memory Technology: {module['memory_technology']}") + if 'type_detail' in module: + lines.append(f" Type Detail: {module['type_detail']}") + if 'minimum_voltage' in module: + lines.append(f" Minimum Voltage: {module['minimum_voltage']}") + if 'maximum_voltage' in module: + lines.append(f" Maximum Voltage: {module['maximum_voltage']}") + if 'location' in module: + lines.append(f" Location: {module['location']}") + + + # Top Memory Processes + processes = info['top_memory_processes'] + if processes: + lines.append(f"\nTop {len(processes)} Memory-Consuming Processes:") + for proc in processes: + lines.append(f" PID {proc['pid']:6d} - {proc['name']:20s}: {proc['memory_human']}") + + # Virtualization + if info['is_virtual']: + lines.append(f"\nVirtualization: {info['virtual_type']}") + + # Load Average + stats = info['memory_stats'] + if stats.get('load_average'): + la = stats['load_average'] + lines.append(f"\nLoad Average: {la['1min']:.2f} (1min), {la['5min']:.2f} (5min), {la['15min']:.2f} (15min)") + + # Memory Pressure (if available) + if stats.get('memory_pressure'): + lines.append("\nMemory Pressure:") + pressure = stats['memory_pressure'] + if 'some' in pressure: + lines.append(f" Some: avg10={pressure['some']['avg10']:.2f}, avg60={pressure['some']['avg60']:.2f}, avg300={pressure['some']['avg300']:.2f}") + + lines.append("\n" + "=" * 80) + + return '\n'.join(lines) + + def get_memory_usage_warning(self, threshold_percent: float = 90.0) -> Optional[str]: + """ + Check if memory usage exceeds threshold and return warning. + threshold_percent: Threshold percentage for warning (default 90%) + """ + info = self.get_memory_info() + phys = info['physical_memory'] + + if phys.get('used_percent') and phys['used_percent'] >= threshold_percent: + return (f"WARNING: Memory usage is {phys['used_percent']:.1f}% " + f"({phys['used_human']} used of {phys['total_human']} total)") + + swap = info['swap_memory'] + if swap.get('used_percent') and swap.get('total_bytes', 0) > 0: + if swap['used_percent'] >= threshold_percent: + return (f"WARNING: Swap usage is {swap['used_percent']:.1f}% " + f"({swap['used_human']} used of {swap['total_human']} total)") + + return None + + def refresh(self) -> 'MemoryInfo': + """Clear cache and refresh memory information.""" + self._info_cache = {} + return self + + @staticmethod + def _bytes_to_human(bytes_val: Optional[int]) -> str: + """Convert bytes to human-readable format.""" + if bytes_val is None: + return "Unknown" + + for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB']: + if bytes_val < 1024.0: + return f"{bytes_val:.2f} {unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.2f} EB" + + @staticmethod + def _check_command_exists(command: str) -> bool: + """Check if a command exists in the system.""" + import subprocess + try: + subprocess.run(['which', command], capture_output=True, timeout=5) + return True + except Exception: + return False + + @staticmethod + def _run_command(command: List[str], timeout: int = 10) -> Optional[str]: + """Run a command and return its output.""" + import subprocess + try: + result = subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout + ) + return result.stdout if result.returncode == 0 else None + except Exception: + return None + + def __repr__(self) -> str: + info = self.get_memory_info() + phys = info['physical_memory'] + total = phys.get('total_human', 'Unknown') + used_percent = phys.get('used_percent', 0) + return f"MemoryInfo(total={total}, used={used_percent:.1f}%)" + + +# ------------------------------------------------------------------ +# Example usage and test +# ------------------------------------------------------------------ +if __name__ == '__main__': + memory = MemoryInfo() + + # Print summary + print(memory.get_memory_summary()) + + # Get detailed information as dictionary + print("\n" + "=" * 80) + print("DETAILED INFORMATION (JSON-like structure)") + print("=" * 80) + + info = memory.get_memory_info() + + print("\nPhysical Memory:") + phys = info['physical_memory'] + for key, value in phys.items(): + if value is not None: + print(f" {key}: {value}") + + print("\nSwap Memory:") + swap = info['swap_memory'] + for key, value in swap.items(): + if value is not None and key != 'swap_devices': + print(f" {key}: {value}") + + if swap.get('swap_devices'): + print("\n Swap Devices:") + for dev in swap['swap_devices']: + print(f" - {dev}") + + print("\nVirtual Memory Statistics:") + vm_stats = info['virtual_memory'] + for key, value in vm_stats.items(): + if value is not None: + print(f" {key}: {value:,}") + + if info['numa_info']: + print("\nNUMA Information:") + numa = info['numa_info'] + print(f" NUMA Enabled: {numa['numa_enabled']}") + if numa['numa_nodes']: + print(f" NUMA Nodes: {len(numa['numa_nodes'])}") + for node in numa['numa_nodes']: + print(f" Node {node['node_id']}: {node.get('memory_human', 'Unknown')}, " + f"CPUs: {node['cpus'][:10]}{'...' if len(node['cpus']) > 10 else ''}") + + # Check for warnings + warning = memory.get_memory_usage_warning(threshold_percent=80) + if warning: + print(f"\n{warning}") + else: + print("\nMemory usage is within normal limits") + + # Monitor changes over time (example) + print("\n" + "=" * 80) + print("MONITORING MEMORY CHANGES (5 second interval)") + print("=" * 80) + + import time + for i in range(3): + phys = memory.get_memory_info()['physical_memory'] + print(f"\nSample {i+1}: Used: {phys['used_human']} ({phys['used_percent']:.1f}%), " + f"Available: {phys['available_human']}") + + if i < 2: + time.sleep(5) + memory.refresh() # Refresh data for next sample \ No newline at end of file diff --git a/lib/sunhpc/sunhpc/network.py b/lib/sunhpc/sunhpc/network.py new file mode 100644 index 0000000..7317279 --- /dev/null +++ b/lib/sunhpc/sunhpc/network.py @@ -0,0 +1,523 @@ +""" +Network class to retrieve network interface information and manipulate IP addresses. +No third-party libraries required, uses /sys filesystem and ioctl system calls. +""" +import os +import re +import struct +import socket +import fcntl +from typing import Optional, Tuple, List, Union + + +class Network: + def __init__(self): + self.iface: Optional[str] = None + self._ip: Optional[str] = None + self._netmask: Optional[str] = None + self._network: Optional[str] = None + self._gateway: Optional[str] = None + self._mac: Optional[str] = None + self._bytes_rx: Optional[int] = None + self._bytes_tx: Optional[int] = None + self._cidr: Optional[int] = None + self._ipv6: Optional[List[str]] = None + self._manual_ip: bool = False # Flag to track if IP was manually set + self._manual_netmask: bool = False # Flag to track if netmask was manually set + + def setiface(self, iface: str) -> 'Network': + """Set the network interface and refresh all information.""" + self.iface = iface + # Reset manual flags when changing interface + self._manual_ip = False + self._manual_netmask = False + self._refresh_all() + return self + + def setip(self, ip: str) -> 'Network': + """Manually set the IPv4 address (overrides system value).""" + self._ip = ip + self._manual_ip = True + # Update dependent values + if self._manual_netmask: + self._update_dependent_values() + else: + # Only update network and cidr if we have netmask + self._network = self.getnetwork() + self._cidr = self.getcidr() + return self + + def setnetmask(self, netmask: str) -> 'Network': + """Manually set the netmask (overrides system value).""" + self._netmask = netmask + self._manual_netmask = True + # Update dependent values + if self._manual_ip: + self._update_dependent_values() + else: + # Only update network and cidr if we have IP + self._network = self.getnetwork() + self._cidr = self.getcidr() + return self + + def _update_dependent_values(self): + """Update network and CIDR based on current IP and netmask.""" + if self._ip and self._netmask: + self._network = self._calculate_network(self._ip, self._netmask) + self._cidr = self._calculate_cidr(self._netmask) + else: + self._network = None + self._cidr = None + + def _refresh_all(self): + """Refresh all interface information respecting manual overrides.""" + if not self.iface: + return + + # Only get from system if not manually set + if not self._manual_ip: + self._ip = self.getip() + if not self._manual_netmask: + self._netmask = self.getnetmask() + + # Always refresh these from system (they can't be manually set) + self._gateway = self.getgateway() + self._mac = self.getmac() + self._bytes_rx, self._bytes_tx = self.get_transfer_bytes() + self._ipv6 = self.getipv6() + + # Update dependent values + self._update_dependent_values() + + # ------------------------------------------------------------------ + # Public getter methods + # ------------------------------------------------------------------ + def getip(self) -> Optional[str]: + """Get IPv4 address of the interface.""" + if self._manual_ip: + return self._ip + return self._get_ip_from_ioctl() + + def getnetmask(self) -> Optional[str]: + """Get netmask of the interface.""" + if self._manual_netmask: + return self._netmask + return self._get_netmask_from_ioctl() + + def getnetwork(self) -> Optional[str]: + """Calculate network address from IP and netmask.""" + if self._network is not None: + return self._network + ip = self.getip() + mask = self.getnetmask() + return self._calculate_network(ip, mask) + + def _calculate_network(self, ip: Optional[str], mask: Optional[str]) -> Optional[str]: + """Calculate network address from IP and netmask.""" + if not ip or not mask: + return None + try: + ip_int = self._ip_to_int(ip) + mask_int = self._ip_to_int(mask) + network_int = ip_int & mask_int + return self._int_to_ip(network_int) + except Exception: + return None + + def getgateway(self) -> Optional[str]: + """Get default gateway for the interface.""" + return self._get_gateway_from_proc() + + def getmac(self) -> Optional[str]: + """Get MAC address of the interface.""" + return self._get_mac_from_sys() + + def get_transfer_bytes(self) -> Tuple[Optional[int], Optional[int]]: + """Get total received and transmitted bytes (raw integers).""" + rx, tx = self._get_stats_from_sys() + return rx, tx + + def get_transfer_human(self) -> Tuple[Optional[str], Optional[str]]: + """Get total received and transmitted bytes in human-readable format.""" + rx, tx = self.get_transfer_bytes() + return self._bytes_to_human(rx), self._bytes_to_human(tx) + + def getcidr(self) -> Optional[int]: + """Get CIDR prefix length from netmask.""" + if self._cidr is not None: + return self._cidr + mask = self.getnetmask() + return self._calculate_cidr(mask) + + def _calculate_cidr(self, mask: Optional[str]) -> Optional[int]: + """Calculate CIDR from netmask.""" + if not mask: + return None + try: + mask_int = self._ip_to_int(mask) + # Count leading 1 bits + cidr = bin(mask_int).count('1') + return cidr if 0 <= cidr <= 32 else None + except Exception: + return None + + def getipv6(self) -> Optional[List[str]]: + """Get all IPv6 addresses of the interface (link-local and global).""" + return self._get_ipv6_from_proc() + + # ------------------------------------------------------------------ + # IP iteration (inc parameter) + # ------------------------------------------------------------------ + def iter_ip(self, inc: int = 1, ip: Optional[str] = None, netmask: Optional[str] = None) -> Optional[str]: + """ + Return next or previous IP address based on increment. + + Args: + inc: Increment step (positive for forward, negative for backward) + ip: Optional custom IP address (uses current IP if not provided) + netmask: Optional custom netmask (uses current netmask if not provided) + + Examples: + net.iter_ip(1) # Use current IP and netmask + net.iter_ip(-1, '10.1.1.1', '255.255.255.0') # Custom IP and netmask + net.iter_ip(2, ip='192.168.1.1', netmask='24') # CIDR notation supported + """ + # Use provided values or fall back to current settings + use_ip = ip if ip is not None else self.getip() + use_netmask = netmask if netmask is not None else self.getnetmask() + + # Convert CIDR notation to netmask if needed + if use_netmask and isinstance(use_netmask, str) and '/' not in use_netmask: + if use_netmask.isdigit() and 0 <= int(use_netmask) <= 32: + use_netmask = self._cidr_to_netmask(int(use_netmask)) + + if not use_ip or not use_netmask: + return None + + try: + ip_int = self._ip_to_int(use_ip) + mask_int = self._ip_to_int(use_netmask) + network_int = ip_int & mask_int + broadcast_int = network_int | (~mask_int & 0xFFFFFFFF) + + # Calculate new IP + new_ip_int = ip_int + inc + + # Clamp to network range (exclude network and broadcast addresses) + if new_ip_int <= network_int: + # Wrap to the end of network (minus broadcast) + new_ip_int = broadcast_int - 1 + elif new_ip_int >= broadcast_int: + # Wrap to the start of network (plus network address) + new_ip_int = network_int + 1 + + # Ensure we don't return network or broadcast address + if new_ip_int == network_int: + new_ip_int = broadcast_int - 1 + elif new_ip_int == broadcast_int: + new_ip_int = network_int + 1 + + return self._int_to_ip(new_ip_int) + except Exception: + return None + + def iter_ip_range(self, start_ip: str, netmask: str, steps: int = 10) -> List[str]: + """ + Generate a range of IP addresses. + + Args: + start_ip: Starting IP address + netmask: Netmask (dotted decimal or CIDR) + steps: Number of steps to generate (can be positive or negative) + + Returns: + List of IP addresses + """ + results = [] + for i in range(1, abs(steps) + 1): + step = i if steps > 0 else -i + next_ip = self.iter_ip(step, start_ip, netmask) + if next_ip: + results.append(next_ip) + return results + + @staticmethod + def _cidr_to_netmask(cidr: int) -> str: + """Convert CIDR prefix length to dotted netmask.""" + mask_int = (0xFFFFFFFF << (32 - cidr)) & 0xFFFFFFFF + return f"{(mask_int >> 24) & 0xFF}.{(mask_int >> 16) & 0xFF}.{(mask_int >> 8) & 0xFF}.{mask_int & 0xFF}" + + # ------------------------------------------------------------------ + # Low-level implementation methods (with fallbacks) + # ------------------------------------------------------------------ + def _get_ip_from_ioctl(self) -> Optional[str]: + """Get IPv4 address using ioctl SIOCGIFADDR.""" + if not self.iface: + return None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ifreq = struct.pack('16s256s', self.iface.encode(), b'\x00'*256) + res = fcntl.ioctl(sock.fileno(), 0x8915, ifreq) # SIOCGIFADDR + sock.close() + ip_bytes = res[20:24] + return socket.inet_ntoa(ip_bytes) + except Exception: + return self._get_ip_from_sys() + + def _get_ip_from_sys(self) -> Optional[str]: + """Fallback: read IP from /sys/class/net//address.""" + if not self.iface: + return None + path = f'/sys/class/net/{self.iface}/address' + # Note: /sys doesn't directly provide IPv4, try /proc instead + return self._get_ip_from_proc() + + def _get_ip_from_proc(self) -> Optional[str]: + """Read IPv4 from /proc/net/fib_trie or /proc/net/route.""" + if not self.iface: + return None + try: + with open('/proc/net/fib_trie', 'r') as f: + content = f.read() + # Parse the trie for the interface + lines = content.split('\n') + in_correct_host = False + for line in lines: + if f"|-- {self.iface}" in line: + in_correct_host = True + elif in_correct_host and '|--' in line and 'host' in line: + parts = line.split() + for part in parts: + if re.match(r'^\d+\.\d+\.\d+\.\d+$', part): + return part + in_correct_host = False + except Exception: + pass + return None + + def _get_netmask_from_ioctl(self) -> Optional[str]: + """Get netmask using ioctl SIOCGIFNETMASK.""" + if not self.iface: + return None + try: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + ifreq = struct.pack('16s256s', self.iface.encode(), b'\x00'*256) + res = fcntl.ioctl(sock.fileno(), 0x891b, ifreq) # SIOCGIFNETMASK + sock.close() + mask_bytes = res[20:24] + return socket.inet_ntoa(mask_bytes) + except Exception: + return None + + def _get_mac_from_sys(self) -> Optional[str]: + """Read MAC address from /sys/class/net//address.""" + if not self.iface: + return None + path = f'/sys/class/net/{self.iface}/address' + try: + with open(path, 'r') as f: + mac = f.read().strip() + if re.match(r'([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}', mac): + return mac.upper() + except Exception: + pass + return None + + def _get_stats_from_sys(self) -> Tuple[Optional[int], Optional[int]]: + """Read rx_bytes and tx_bytes from /sys/class/net//statistics/.""" + if not self.iface: + return None, None + rx_path = f'/sys/class/net/{self.iface}/statistics/rx_bytes' + tx_path = f'/sys/class/net/{self.iface}/statistics/tx_bytes' + rx = self._read_int_file(rx_path) + tx = self._read_int_file(tx_path) + return rx, tx + + def _get_gateway_from_proc(self) -> Optional[str]: + """Parse /proc/net/route to find default gateway for the interface.""" + if not self.iface: + return None + try: + with open('/proc/net/route', 'r') as f: + lines = f.readlines()[1:] # skip header + for line in lines: + parts = line.strip().split() + if len(parts) >= 3 and parts[0] == self.iface: + dest = int(parts[1], 16) + gateway = int(parts[2], 16) + if dest == 0: # default route + return self._int_to_ip(gateway) + except Exception: + pass + return None + + def _get_ipv6_from_proc(self) -> Optional[List[str]]: + """Parse /proc/net/if_inet6 for IPv6 addresses of the interface.""" + if not self.iface: + return None + ipv6_list = [] + try: + with open('/proc/net/if_inet6', 'r') as f: + for line in f: + parts = line.strip().split() + if len(parts) >= 4 and parts[3] == self.iface: + ipv6_hex = parts[0] + # Convert hex to IPv6 notation + ipv6 = ':'.join(ipv6_hex[i:i+4] for i in range(0, 32, 4)) + # Compress IPv6 (optional) + ipv6 = self._compress_ipv6(ipv6) + ipv6_list.append(ipv6) + return ipv6_list if ipv6_list else None + except Exception: + return None + + @staticmethod + def _compress_ipv6(ipv6: str) -> str: + """Compress IPv6 address.""" + try: + # Expand to full 8 groups + groups = ipv6.split(':') + if len(groups) < 8: + groups = ['0'] * (8 - len(groups)) + groups + # Find longest run of zeros + best_start = -1 + best_len = 0 + current_start = -1 + current_len = 0 + for i, group in enumerate(groups): + if group == '0': + if current_start == -1: + current_start = i + current_len += 1 + else: + if current_len > best_len: + best_start = current_start + best_len = current_len + current_start = -1 + current_len = 0 + if current_len > best_len: + best_start = current_start + best_len = current_len + + if best_len > 1: + # Compress + start = best_start + end = best_start + best_len + compressed = groups[:start] + [''] + groups[end:] + ipv6 = ':'.join(compressed).replace(':::', '::') + else: + # Remove leading zeros from each group + ipv6 = ':'.join(g.lstrip('0') or '0' for g in groups) + return ipv6 + except Exception: + return ipv6 + + @staticmethod + def _read_int_file(path: str) -> Optional[int]: + """Read integer from a file.""" + try: + with open(path, 'r') as f: + return int(f.read().strip()) + except Exception: + return None + + @staticmethod + def _ip_to_int(ip: str) -> int: + """Convert dotted IPv4 to integer.""" + parts = ip.split('.') + return (int(parts[0]) << 24) + (int(parts[1]) << 16) + (int(parts[2]) << 8) + int(parts[3]) + + @staticmethod + def _int_to_ip(ip_int: int) -> str: + """Convert integer to dotted IPv4.""" + return f"{(ip_int >> 24) & 0xFF}.{(ip_int >> 16) & 0xFF}.{(ip_int >> 8) & 0xFF}.{ip_int & 0xFF}" + + @staticmethod + def _bytes_to_human(bytes_val: Optional[int]) -> Optional[str]: + """Convert bytes to human-readable format.""" + if bytes_val is None: + return None + for unit in ['B', 'KiB', 'MiB', 'GiB', 'TiB']: + if bytes_val < 1024.0: + return f"{bytes_val:.2f} {unit}" + bytes_val /= 1024.0 + return f"{bytes_val:.2f} PiB" + + # ------------------------------------------------------------------ + # Utility methods + # ------------------------------------------------------------------ + def reset_to_system(self) -> 'Network': + """Reset manually set values back to system values.""" + self._manual_ip = False + self._manual_netmask = False + self._refresh_all() + return self + + def __repr__(self) -> str: + if not self.iface: + return "Network(no interface set)" + manual_info = [] + if self._manual_ip: + manual_info.append("manual_ip") + if self._manual_netmask: + manual_info.append("manual_netmask") + manual_str = f", manual={manual_info}" if manual_info else "" + return (f"Network(iface={self.iface}, ip={self._ip}, netmask={self._netmask}, " + f"gateway={self._gateway}, mac={self._mac}, cidr={self._cidr}, " + f"ipv6={self._ipv6}{manual_str})") + + +# ------------------------------------------------------------------ +# Example usage and test +# ------------------------------------------------------------------ +if __name__ == '__main__': + net = Network() + net.setiface('eth0') # Change to your interface (e.g., 'wlan0', 'enp0s3') + + print("=== System Interface Information ===") + print(f"Interface: {net.iface}") + print(f"IP: {net.getip()}") + print(f"Netmask: {net.getnetmask()}") + print(f"Network: {net.getnetwork()}") + print(f"Gateway: {net.getgateway()}") + print(f"MAC: {net.getmac()}") + print(f"CIDR: {net.getcidr()}") + print(f"IPv6: {net.getipv6()}") + + rx, tx = net.get_transfer_human() + print(f"RX bytes: {rx}") + print(f"TX bytes: {tx}") + + print("\n=== Testing Custom IP/Netmask ===") + # Set custom IP and netmask + net.setip('10.1.1.1') + net.setnetmask('255.255.255.0') + # Or use CIDR notation: net.setnetmask('24') + + print(f"Current IP: {net.getip()} (manually set)") + print(f"Current Netmask: {net.getnetmask()} (manually set)") + print(f"Network: {net.getnetwork()}") + print(f"CIDR: {net.getcidr()}") + + print("\n=== IP Iteration Tests ===") + # Test with current settings + print(f"Using IP {net.getip()}, Netmask {net.getnetmask()}:") + print(f"inc=1 -> {net.iter_ip(1)}") + print(f"inc=2 -> {net.iter_ip(2)}") + print(f"inc=-1 -> {net.iter_ip(-1)}") + print(f"inc=-2 -> {net.iter_ip(-2)}") + + # Test with custom values directly in iter_ip + print("\n=== Custom Values in iter_ip ===") + print(f"iter_ip(1, '192.168.1.1', '24'): {net.iter_ip(1, '192.168.1.1', '24')}") + print(f"iter_ip(-1, '192.168.1.1', '255.255.255.0'): {net.iter_ip(-1, '192.168.1.1', '255.255.255.0')}") + print(f"iter_ip(5, '10.0.0.1', '255.255.0.0'): {net.iter_ip(5, '10.0.0.1', '255.255.0.0')}") + + # Generate a range of IPs + print("\n=== IP Range Generation ===") + range_ips = net.iter_ip_range('10.1.1.1', '24', 5) + print(f"Next 5 IPs: {range_ips}") + + # Reset back to system values + net.reset_to_system() + print(f"\nAfter reset: IP={net.getip()} (back to system value)") \ No newline at end of file diff --git a/lib/sunhpc/sunhpc/output.py b/lib/sunhpc/sunhpc/output.py new file mode 100644 index 0000000..f61a656 --- /dev/null +++ b/lib/sunhpc/sunhpc/output.py @@ -0,0 +1,922 @@ +""" +Output class for creating beautifully aligned tables with flexible data input. +Supports headers, multiple data formats (list, tuple, dict), automatic column sizing, +and customizable empty value handling. +""" + +from decimal import Decimal +from sunhpc.logs import Logs +from typing import List, Dict, Any, Optional, Union, Tuple + +slog = Logs(name="Output", show_time=True) + +class Output: + """ + A flexible table class for aligned output with header support. + + Examples: + table = Output() + table.add_header(['name', 'age', 'city']) + table.add_row(['Alice', 28, 'New York']) + table.add_row({'name': 'Bob', 'age': 32, 'city': 'Los Angeles'}) + table.add_row(('Charlie', 25, 'Chicago')) + table.print_table() + """ + + def __init__( + self, + headers: Optional[List[str]] = None, + align: str = 'left', + empty_fill: str = 'N/A', + separator: str = ' ', + border_style: str = 'simple', # 'simple', 'grid', 'none' + header_separator_char: str = '-', + max_width: Optional[int] = None, + truncate_suffix: str = '...', + auto_align_numeric: bool = True + ): + """ + Initialize the Output. + + Args: + headers: Initial list of column headers + align: Default alignment for columns ('left', 'right', 'center') + empty_fill: String to fill empty/missing values + separator: Column separator string + border_style: Style of borders ('simple', 'grid', 'none') + header_separator_char: Character for header underline + max_width: Maximum width for any column (truncates if exceeded) + truncate_suffix: Suffix for truncated values + auto_align_numeric: Automatically right-align numeric values + """ + self.headers: List[str] = headers if headers else [] + self.data: List[List[Any]] = [] + self.alignments: Dict[str, str] = {} + self.default_align = align + self.empty_fill = empty_fill + self.separator = separator + self.border_style = border_style + self.header_separator_char = header_separator_char + self.max_width = max_width + self.truncate_suffix = truncate_suffix + self.auto_align_numeric = auto_align_numeric + + # Column order (if headers are provided) + self.column_order = headers.copy() if headers else [] + + # Initialize alignments for headers + if headers: + for header in headers: + self.alignments[header] = align + + def add_header(self, headers: List[str]) -> 'Output': + """ + Add or replace table headers. + + Args: + headers: List of column header names + + Returns: + Self for method chaining + """ + self.headers = headers + self.column_order = headers.copy() + + # Initialize alignments for new headers + for header in headers: + if header not in self.alignments: + self.alignments[header] = self.default_align + + return self + + def set_alignment(self, column: str, align: str) -> 'Output': + """ + Set alignment for a specific column. + + Args: + column: Column header name + align: Alignment ('left', 'right', 'center') + + Returns: + Self for method chaining + """ + if column in self.headers: + self.alignments[column] = align + return self + + def set_empty_fill(self, empty_fill: str) -> 'Output': + """ + Set the string to fill empty/missing values. + + Args: + empty_fill: String to use for filling empty/missing values + + Returns: + Self for method chaining + """ + self.empty_fill = empty_fill + return self + + def set_separator(self, separator: str) -> 'Output': + """ + Set the column separator string. + + Args: + separator: String to use for separating columns + + Returns: + Self for method chaining + """ + self.separator = separator + return self + + def set_border_style(self, border_style: str) -> 'Output': + """ + Set the style of borders. + + Args: + border_style: Style of borders ('simple', 'grid', 'none') + + Returns: + Self for method chaining + """ + self.border_style = border_style + return self + + def set_header_separator_char(self, header_separator_char: str) -> 'Output': + """ + Set the character for header underline. + + Args: + header_separator_char: Character to use for header underline + + Returns: + Self for method chaining + """ + self.header_separator_char = header_separator_char + return self + + def set_max_width(self, max_width: Optional[int] = None) -> 'Output': + """ + Set the maximum width for any column. + + Args: + max_width: Maximum width for any column (truncates if exceeded) + + Returns: + Self for method chaining + """ + self.max_width = max_width + return self + + def set_truncate_suffix(self, truncate_suffix: str = '...') -> 'Output': + """ + Set the suffix for truncated values. + + Args: + truncate_suffix: Suffix to use for truncated values + + Returns: + Self for method chaining + """ + self.truncate_suffix = truncate_suffix + return self + + def set_auto_align_numeric(self, auto_align_numeric: bool = True) -> 'Output': + """ + Set whether to automatically align numeric values. + + Args: + auto_align_numeric: Whether to align numeric values ('left' or 'right') + + Returns: + Self for method chaining + """ + self.auto_align_numeric = auto_align_numeric + return self + + def add_row( + self, + row: Union[List[Any], Tuple[Any, ...], Dict[str, Any]], + auto_fill: bool = True + ) -> 'Output': + """ + Add a row to the table. + + Args: + row: Row data as list, tuple, or dictionary + auto_fill: Automatically fill missing columns with empty_fill + + Returns: + Self for method chaining + """ + if not self.headers: + raise ValueError("Headers must be set before adding rows. Use add_header() first.") + + processed_row = [] + + if isinstance(row, dict): + # Dictionary format: map values by header + for header in self.headers: + value = row.get(header) + if value is None and header not in row: + if auto_fill: + value = self.empty_fill + else: + value = '' + processed_row.append(value) + elif isinstance(row, (list, tuple)): + # List/tuple format: match by position + row_list = list(row) + for i, header in enumerate(self.headers): + if i < len(row_list): + processed_row.append(row_list[i]) + elif auto_fill: + processed_row.append(self.empty_fill) + else: + processed_row.append('') + else: + raise TypeError(f"Row must be list, tuple, or dict, got {type(row)}") + + self.data.append(processed_row) + return self + + def add_rows( + self, + rows: List[Union[List[Any], Tuple[Any, ...], Dict[str, Any]]], + auto_fill: bool = True + ) -> 'Output': + """ + Add multiple rows to the table. + + Args: + rows: List of rows (each row as list, tuple, or dict) + auto_fill: Automatically fill missing columns with empty_fill + + Returns: + Self for method chaining + """ + for row in rows: + self.add_row(row, auto_fill) + return self + + def insert_row( + self, + index: int, + row: Union[List[Any], Tuple[Any, ...], Dict[str, Any]], + auto_fill: bool = True + ) -> 'Output': + """ + Insert a row at a specific index. + + Args: + index: Position to insert the row + row: Row data as list, tuple, or dictionary + auto_fill: Automatically fill missing columns with empty_fill + + Returns: + Self for method chaining + """ + if not self.headers: + raise ValueError("Headers must be set before inserting rows. Use add_header() first.") + + processed_row = [] + + if isinstance(row, dict): + for header in self.headers: + value = row.get(header) + if value is None and header not in row: + if auto_fill: + value = self.empty_fill + else: + value = '' + processed_row.append(value) + elif isinstance(row, (list, tuple)): + row_list = list(row) + for i, header in enumerate(self.headers): + if i < len(row_list): + processed_row.append(row_list[i]) + elif auto_fill: + processed_row.append(self.empty_fill) + else: + processed_row.append('') + else: + raise TypeError(f"Row must be list, tuple, or dict, got {type(row)}") + + self.data.insert(index, processed_row) + return self + + def remove_row(self, index: int) -> 'Output': + """ + Remove a row at a specific index. + + Args: + index: Position of row to remove + + Returns: + Self for method chaining + """ + if 0 <= index < len(self.data): + self.data.pop(index) + return self + + def clear(self) -> 'Output': + """Clear all data rows but keep headers.""" + self.data = [] + return self + + def reset(self) -> 'Output': + """Reset the entire table (headers and data).""" + self.headers = [] + self.data = [] + self.column_order = [] + self.alignments = {} + return self + + def _calculate_column_widths(self) -> Dict[str, int]: + """Calculate optimal width for each column.""" + if not self.headers: + return {} + + widths = {} + + # Initialize with header widths + for i, header in enumerate(self.headers): + widths[header] = len(str(header)) + + # Check data widths + for row in self.data: + for i, header in enumerate(self.headers): + if i < len(row): + value = row[i] + # Handle None values + if value is None: + value = '' + # Handle special types + if isinstance(value, (int, float, Decimal)): + # Format numbers for width calculation + if isinstance(value, float): + value_str = f"{value:.2f}" if value == int(value) else str(value) + else: + value_str = str(value) + else: + value_str = str(value) + + # Apply max width truncation for calculation + if self.max_width and len(value_str) > self.max_width: + value_str = value_str[:self.max_width - len(self.truncate_suffix)] + self.truncate_suffix + + widths[header] = max(widths[header], len(value_str)) + + return widths + + def _format_value(self, value: Any, width: int, column: str) -> str: + """Format a single value with proper alignment and truncation.""" + # Handle None values + if value is None: + value = self.empty_fill + + # Convert to string + if isinstance(value, (int, float, Decimal)): + # Auto-right align numbers if enabled + if self.auto_align_numeric and self.alignments.get(column, self.default_align) == 'left': + align = 'right' + else: + align = self.alignments.get(column, self.default_align) + + # Format numbers nicely + if isinstance(value, float): + # Show 2 decimal places for floats that are not integers + if value == int(value): + value_str = str(int(value)) + else: + value_str = f"{value:.2f}" + else: + value_str = str(value) + elif isinstance(value, bool): + value_str = "True" if value else "False" + align = self.alignments.get(column, self.default_align) + else: + value_str = str(value) + align = self.alignments.get(column, self.default_align) + + # Truncate if exceeds max width + if self.max_width and len(value_str) > self.max_width: + value_str = value_str[:self.max_width - len(self.truncate_suffix)] + self.truncate_suffix + + # Apply alignment + if align == 'left': + return value_str.ljust(width) + elif align == 'right': + return value_str.rjust(width) + elif align == 'center': + return value_str.center(width) + else: + return value_str.ljust(width) + + def _format_row(self, row: List[Any], widths: Dict[str, int]) -> List[str]: + """Format an entire row.""" + formatted_cells = [] + for i, header in enumerate(self.headers): + if i < len(row): + value = row[i] + else: + value = self.empty_fill + formatted_cells.append(self._format_value(value, widths[header], header)) + return formatted_cells + + def _get_border_line(self, widths: Dict[str, int], style: str = 'simple') -> str: + """Generate border line based on style.""" + if style == 'none': + return '' + + parts = [] + for i, header in enumerate(self.headers): + width = widths[header] + if style == 'grid': + parts.append('+' + '-' * (width + 2)) + else: # simple + parts.append('-' * (width + 2)) + + if style == 'grid': + return '+' + '+'.join(parts) + '+' + else: + return ' ' + ' '.join(parts) + + def _format_header(self, widths: Dict[str, int]) -> str: + """Format the header row.""" + header_cells = [] + for i, header in enumerate(self.headers): + align = self.alignments.get(header, self.default_align) + width = widths[header] + + if align == 'left': + formatted = header.ljust(width) + elif align == 'right': + formatted = header.rjust(width) + else: + formatted = header.center(width) + + header_cells.append(formatted) + + if self.border_style == 'grid': + return '| ' + f' {self.separator} '.join(header_cells) + ' |' + else: + return self.separator.join(header_cells) + + def _format_separator(self, widths: Dict[str, int]) -> str: + """Format the separator line under headers.""" + if self.border_style == 'none': + return '' + + parts = [] + for i, header in enumerate(self.headers): + width = widths[header] + parts.append(self.header_separator_char * width) + + if self.border_style == 'grid': + return '|-' + f'-{self.separator}-'.join(parts) + '-|' + else: + return self.separator.join(parts) + + def _format_data_row(self, row: List[str], is_last: bool = False) -> str: + """Format a data row.""" + if self.border_style == 'grid': + return '| ' + f' {self.separator} '.join(row) + ' |' + else: + return self.separator.join(row) + + def get_table_string(self) -> str: + """ + Generate the complete table as a string. + + Returns: + Formatted table string + """ + if not self.headers: + return "No headers defined" + + if not self.data: + return f"Table '{self.separator.join(self.headers)}' has no data" + + # Calculate column widths + widths = self._calculate_column_widths() + + # Build table lines + lines = [] + + # Header + header_line = self._format_header(widths) + lines.append(header_line) + + # Separator + separator_line = self._format_separator(widths) + if separator_line: + lines.append(separator_line) + + # Data rows + for i, row in enumerate(self.data): + formatted_row = self._format_row(row, widths) + lines.append(self._format_data_row(formatted_row, i == len(self.data) - 1)) + + return '\n'.join(lines) + + def print_table(self) -> None: + """Print the table to console.""" + print(self.get_table_string()) + + def export_to_csv(self, filename: str, include_header: bool = True) -> None: + """ + Export table to CSV file. + + Args: + filename: Output file path + include_header: Whether to include header row + """ + import csv + + with open(filename, 'w', newline='', encoding='utf-8') as csvfile: + writer = csv.writer(csvfile) + + if include_header: + writer.writerow(self.headers) + + for row in self.data: + # Convert values to strings and handle None + processed_row = [str(cell) if cell is not None else '' for cell in row] + writer.writerow(processed_row) + + def get_data_frame(self) -> List[Dict[str, Any]]: + """ + Convert table data to list of dictionaries. + + Returns: + List of dictionaries representing the table data + """ + result = [] + for row in self.data: + row_dict = {} + for i, header in enumerate(self.headers): + if i < len(row): + value = row[i] + # Convert None to empty_fill for consistency + if value is None: + value = self.empty_fill + row_dict[header] = value + else: + row_dict[header] = self.empty_fill + result.append(row_dict) + return result + + def sort_by(self, column: str, reverse: bool = False) -> 'Output': + """ + Sort table rows by a specific column. + + Args: + column: Column header to sort by + reverse: Reverse sort order + + Returns: + Self for method chaining + """ + if column not in self.headers: + raise ValueError(f"Column '{column}' not found in headers") + + column_index = self.headers.index(column) + + # Sort the data + self.data.sort( + key=lambda row: row[column_index] if column_index < len(row) and row[column_index] is not None else '', + reverse=reverse + ) + + return self + + def filter_by(self, column: str, predicate) -> 'Output': + """ + Filter rows based on a predicate function. + + Args: + column: Column header to filter by + predicate: Function that takes a value and returns bool + + Returns: + Self for method chaining + """ + if column not in self.headers: + raise ValueError(f"Column '{column}' not found in headers") + + column_index = self.headers.index(column) + + # Filter the data + self.data = [ + row for row in self.data + if column_index < len(row) and predicate(row[column_index]) + ] + + return self + + def column_exists(self, column: str) -> bool: + """Check if a column exists.""" + return column in self.headers + + def get_column_index(self, column: str) -> Optional[int]: + """Get the index of a column.""" + if column in self.headers: + return self.headers.index(column) + return None + + def get_column_data(self, column: str) -> List[Any]: + """ + Get all values from a specific column. + + Args: + column: Column header name + + Returns: + List of values from that column + """ + if column not in self.headers: + raise ValueError(f"Column '{column}' not found") + + column_index = self.headers.index(column) + return [row[column_index] if column_index < len(row) else self.empty_fill for row in self.data] + + def update_column(self, column: str, values: List[Any]) -> 'Output': + """ + Update values in a specific column. + + Args: + column: Column header name + values: New values for the column (must match row count) + + Returns: + Self for method chaining + """ + if column not in self.headers: + raise ValueError(f"Column '{column}' not found") + + if len(values) != len(self.data): + raise ValueError(f"Number of values ({len(values)}) does not match number of rows ({len(self.data)})") + + column_index = self.headers.index(column) + + for i, value in enumerate(values): + if i < len(self.data): + if column_index >= len(self.data[i]): + # Extend the row if needed + self.data[i].extend([self.empty_fill] * (column_index - len(self.data[i]) + 1)) + self.data[i][column_index] = value + + return self + + def get_statistics(self) -> Dict[str, Any]: + """ + Calculate statistics for numeric columns. + + Returns: + Dictionary with statistics for each numeric column + """ + stats = {} + + for column in self.headers: + column_data = self.get_column_data(column) + numeric_data = [] + + for value in column_data: + try: + if isinstance(value, (int, float)): + numeric_data.append(float(value)) + elif isinstance(value, str) and value.replace('.', '').replace('-', '').isdigit(): + numeric_data.append(float(value)) + except (ValueError, TypeError): + pass + + if numeric_data: + stats[column] = { + 'count': len(numeric_data), + 'min': min(numeric_data), + 'max': max(numeric_data), + 'sum': sum(numeric_data), + 'avg': sum(numeric_data) / len(numeric_data), + 'total_rows': len(column_data) + } + + return stats + + def dict_output(self, data, key_width=None, value_width=None, separator=':', indent=0): + ''' + Print a dictionary as a table. + + Args: + data: Dictionary to print + key_width: Width of the key column + value_width: Width of the value column + separator: Separator between key and value + indent: Indentation level + Returns: + Self for method chaining + ''' + if not isinstance(data, dict): + try: + if isinstance(data, (list, tuple)) and len(data) % 2 == 0: + data = dict(zip(data[::2], data[1::2])) + else: + slog.error(f"Error: Cannot convert {type(data).__name__} to dict") + return self + except: + slog.error(f"Error: Cannot convert {type(data).__name__} to dict") + return self + + if not data: return + + + + # 自动计算键和值的宽度最大宽度,确保对齐 + if key_width is None: + key_width = max(len(key) for key in data.keys()) + + if value_width is None: + value_width = max(len(str(value)) for value in data.values()) + + # 缩进 + indent_str = ' ' * indent + + # 输出每一行 + for key, value in data.items(): + print(f"{indent_str}{str(key):<{key_width}}{separator}{str(value):>{value_width}}") + + return self + + def __len__(self) -> int: + """Return number of rows.""" + return len(self.data) + + def __repr__(self) -> str: + """String representation.""" + if not self.headers: + return "Output(no headers)" + return f"Output(headers={self.headers}, rows={len(self.data)})" + + +# ------------------------------------------------------------------ +# Example usage and test +# ------------------------------------------------------------------ +if __name__ == '__main__': + print("=" * 80) + print("ALIGNED TABLE DEMONSTRATION") + print("=" * 80) + + # Example 1: Basic usage with list rows + print("\n1. Basic table with list rows:") + table1 = Output() + table1.add_header(['Name', 'Age', 'City', 'Country']) + table1.add_row(['Alice', 28, 'New York', 'USA']) + table1.add_row(['Bob', 32, 'Los Angeles', 'USA']) + table1.add_row(['Charlie', 25, 'Chicago', 'USA']) + table1.print_table() + + # Example 2: Dictionary rows with missing values + print("\n2. Dictionary rows with missing values (auto-filled with '--'):") + table2 = Output(headers=['Name', 'Age', 'Email', 'Phone']) + table2.add_row({'Name': 'Alice', 'Age': 28, 'Email': 'alice@example.com'}) + table2.add_row({'Name': 'Bob', 'Age': 32, 'Phone': '123-456-7890'}) + table2.add_row({'Name': 'Charlie', 'Email': 'charlie@example.com', 'Phone': '098-765-4321'}) + table2.print_table() + + # Example 3: Mixed data types and custom alignment + print("\n3. Mixed data types with custom alignment:") + table3 = Output(headers=['Product', 'Price', 'Quantity', 'In Stock']) + table3.set_alignment('Price', 'right') + table3.set_alignment('Quantity', 'right') + table3.add_rows([ + ['Laptop', 999.99, 5, True], + ['Mouse', 19.99, 25, True], + ['Keyboard', 49.99, 0, False], + ['Monitor', 299.99, 3, True] + ]) + table3.print_table() + + # Example 4: Different border styles + print("\n4. Grid border style:") + table4 = Output(headers=['ID', 'Name', 'Score'], border_style='grid') + table4.add_rows([ + [1, 'John', 85.5], + [2, 'Jane', 92.0], + [3, 'Bob', 78.5] + ]) + table4.print_table() + + print("\n5. No border style:") + table4.border_style = 'none' + table4.print_table() + + # Example 6: Tuple rows + print("\n6. Tuple rows:") + table6 = Output(headers=['X', 'Y', 'Z']) + table6.add_rows([ + (10, 20, 30), + (40, 50, 60), + (70, 80, 90) + ]) + table6.print_table() + + # Example 7: Sorting and filtering + print("\n7. Sorting by age (descending):") + table7 = Output(headers=['Name', 'Age', 'Salary']) + table7.add_rows([ + {'Name': 'Alice', 'Age': 28, 'Salary': 50000}, + {'Name': 'Bob', 'Age': 35, 'Salary': 60000}, + {'Name': 'Charlie', 'Age': 25, 'Salary': 45000}, + {'Name': 'Diana', 'Age': 30, 'Salary': 55000} + ]) + table7.sort_by('Age', reverse=True) + table7.print_table() + + print("\n8. Filtered (salary > 50000):") + table8 = Output(headers=['Name', 'Age', 'Salary']) + table8.add_rows([ + {'Name': 'Alice', 'Age': 28, 'Salary': 50000}, + {'Name': 'Bob', 'Age': 35, 'Salary': 60000}, + {'Name': 'Charlie', 'Age': 25, 'Salary': 45000}, + {'Name': 'Diana', 'Age': 30, 'Salary': 55000} + ]) + table8.filter_by('Salary', lambda x: x > 50000 if x is not None else False) + table8.print_table() + + # Example 9: Column operations + print("\n9. Column operations:") + table9 = Output(headers=['Product', 'Price', 'Quantity']) + table9.add_rows([ + ['Apple', 1.2, 100], + ['Banana', 0.8, 150], + ['Orange', 1.5, 80] + ]) + + print("Column 'Price' values:", table9.get_column_data('Price')) + + # Calculate total value + prices = table9.get_column_data('Price') + quantities = table9.get_column_data('Quantity') + totals = [p * q for p, q in zip(prices, quantities)] + + table9.add_header(['Product', 'Price', 'Quantity', 'Total']) + table9.update_column('Total', totals) + table9.print_table() + + # Example 10: Statistics + print("\n10. Statistics for numeric columns:") + table10 = Output(headers=['Name', 'Math', 'Science', 'English']) + table10.add_rows([ + ['Alice', 85, 90, 88], + ['Bob', 78, 85, 82], + ['Charlie', 92, 88, 91], + ['Diana', 88, 92, 87] + ]) + + stats = table10.get_statistics() + for column, stat in stats.items(): + if column != 'Name': + print(f" {column}: avg={stat['avg']:.1f}, min={stat['min']}, max={stat['max']}") + + # Example 11: Export to CSV (uncomment to test) + # table10.export_to_csv('grades.csv') + + # Example 12: Complex real-world example + print("\n11. Real-world example - Employee Directory:") + employees = Output( + headers=['Employee ID', 'Name', 'Department', 'Position', 'Salary', 'Status'], + align='left', + empty_fill='N/A', + separator=' │ ', + border_style='grid' + ) + + employees.set_alignment('Salary', 'right') + employees.set_alignment('Employee ID', 'center') + + employees.add_rows([ + {'Employee ID': 1001, 'Name': 'John Smith', 'Department': 'Engineering', 'Position': 'Senior Developer', 'Salary': 95000, 'Status': 'Active'}, + {'Employee ID': 1002, 'Name': 'Jane Doe', 'Department': 'Marketing', 'Position': 'Manager', 'Salary': 85000, 'Status': 'Active'}, + {'Employee ID': 1003, 'Name': 'Bob Johnson', 'Department': 'Engineering', 'Position': 'Developer', 'Salary': 75000, 'Status': 'On Leave'}, + {'Employee ID': 1004, 'Name': 'Alice Brown', 'Department': 'Sales', 'Position': 'Representative', 'Salary': 65000, 'Status': 'Active'}, + {'Employee ID': 1005, 'Name': 'Charlie Wilson', 'Department': 'Engineering', 'Position': 'Lead', 'Salary': 110000, 'Status': 'Active'}, + {'Employee ID': 1006, 'Name': 'Diana Miller', 'Department': 'HR', 'Position': 'Specialist', 'Salary': 60000, 'Status': 'Inactive'}, + ]) + + employees.print_table() + + # Example 13: Get data as dictionary list + print("\n12. Export to dictionary list:") + data_dict = table10.get_data_frame() + for row in data_dict[:2]: # Show first 2 rows + print(f" {row}") + + # Example 14: Chaining methods + print("\n13. Method chaining example:") + (Output(headers=['Item', 'Count']) + .add_row(['Apples', 10]) + .add_row(['Bananas', 15]) + .add_row(['Oranges', 8]) + .sort_by('Count', reverse=True) + .print_table()) \ No newline at end of file diff --git a/lib/sunhpc/sunhpc/util.py b/lib/sunhpc/sunhpc/util.py index 4a2551a..8e07793 100644 --- a/lib/sunhpc/sunhpc/util.py +++ b/lib/sunhpc/sunhpc/util.py @@ -1,8 +1,10 @@ import os import sys +import shutil import xml.sax import subprocess from xml.sax import handler +from typing import Dict, Any, Optional, List, Union class SunhpcException(Exception): 'Base class for Sunhpc exceptions.' @@ -97,6 +99,15 @@ def getNativeArch(): return 'i386' return arch +def getTerminalWidth(): + get_terminal_size = getattr(os, 'get_terminal_size', shutil.get_terminal_size) + try: + width = get_terminal_size().columns + except Exception: + width = 80 + + return width + def mkdir(newdir): if os.path.isdir(newdir): pass @@ -181,3 +192,4 @@ def startSpinner(cmd): for i in range(0, 78): pad = pad + ' ' print ('\r%s\r' % pad, end='', flush=True) +