Files
Linux_DEL_helper/disk_manager.py
T
2026-06-26 16:54:55 +08:00

507 lines
16 KiB
Python

"""
磁盘管理模块
提供分区检测、挂载、卸载等功能
"""
import json
import os
import subprocess
import tempfile
from typing import List, Dict, Optional, Tuple
from dataclasses import dataclass
from utils import run_command, format_size
@dataclass
class Partition:
"""分区信息"""
device: str # 设备名,如 /dev/sda1
mount_point: str # 挂载点,如 /
fstype: str # 文件系统类型,如 ext4
size: int # 总大小(字节)
used: int # 已用大小(字节)
available: int # 可用大小(字节)
use_percent: float # 使用百分比
label: str = "" # 卷标
is_system: bool = False # 是否为系统盘
@property
def size_formatted(self) -> str:
return format_size(self.size)
@property
def used_formatted(self) -> str:
return format_size(self.used)
@property
def available_formatted(self) -> str:
return format_size(self.available)
@property
def use_percent_formatted(self) -> str:
return f"{self.use_percent:.1f}%"
class DiskManager:
"""磁盘管理器"""
# 排除的文件系统类型
EXCLUDED_FS_TYPES = {
'swap',
'vfat', # 通常是 EFI 分区
'iso9660', # 光盘镜像
'squashfs', # LiveCD 文件系统
'tmpfs', # 临时文件系统
'devtmpfs', # 设备文件系统
'sysfs', # 内核虚拟文件系统
'proc', # 进程虚拟文件系统
'devpts', # 设备终端
'securityfs',
'cgroup',
'pstore',
'debugfs',
'hugetlbfs',
'mqueue',
'fusectl',
'configfs',
'overlay', # Overlay 文件系统
}
# 排除的挂载点前缀
EXCLUDED_MOUNT_PREFIXES = [
'/boot/efi',
'/efi',
'/boot/EFI',
'/snap',
'/cdrom',
'/media/cdrom',
]
def __init__(self, sudo_password: Optional[str] = None):
self.sudo_password = sudo_password
self.mounted_partitions: Dict[str, str] = {} # device -> mount_point
def get_partitions(self) -> List[Partition]:
"""
获取所有可用分区(排除 EFI、swap、LiveCD USB 等)
"""
partitions = []
# 获取 LiveCD 设备
livecd_device = self._detect_livecd_device()
# 使用 lsblk 获取分区信息
stdout, stderr, returncode = run_command(
['lsblk', '-J', '-o', 'NAME,SIZE,TYPE,MOUNTPOINT,FSTYPE,LABEL,PKNAME'],
use_sudo=True,
sudo_password=self.sudo_password
)
if returncode != 0:
# 备用方案:解析 /proc/partitions
return self._get_partitions_from_proc()
try:
data = json.loads(stdout)
devices = data.get('blockdevices', [])
for device in devices:
self._parse_device(device, partitions, livecd_device)
except (json.JSONDecodeError, KeyError) as e:
print(f"解析 lsblk 输出失败: {e}")
return self._get_partitions_from_proc()
# 获取每个分区的使用情况
self._fill_usage_info(partitions)
# 按使用率排序(高的在前)
partitions.sort(key=lambda p: p.use_percent, reverse=True)
return partitions
def _parse_device(
self,
device: dict,
partitions: List[Partition],
livecd_device: Optional[str]
):
"""递归解析设备信息"""
device_name = device.get('name', '')
device_type = device.get('type', '')
fstype = device.get('fstype', '')
mount_point = device.get('mountpoint', '')
label = device.get('label', '')
size_str = device.get('size', '0')
# 只处理分区类型
if device_type == 'part':
device_path = f"/dev/{device_name}"
# 排除条件
if self._should_exclude(device_path, fstype, mount_point, livecd_device):
return
# 解析大小
size = self._parse_size(size_str)
partition = Partition(
device=device_path,
mount_point=mount_point or "",
fstype=fstype or "unknown",
size=size,
used=0,
available=0,
use_percent=0.0,
label=label or "",
is_system=(mount_point == '/')
)
partitions.append(partition)
# 递归处理子设备
children = device.get('children', [])
for child in children:
self._parse_device(child, partitions, livecd_device)
def _should_exclude(
self,
device: str,
fstype: str,
mount_point: str,
livecd_device: Optional[str]
) -> bool:
"""判断是否应该排除此分区"""
# 排除特定文件系统类型
if fstype in self.EXCLUDED_FS_TYPES:
return True
# 排除 EFI 分区
if mount_point and any(mount_point.startswith(prefix) for prefix in self.EXCLUDED_MOUNT_PREFIXES):
return True
# 排除 LiveCD USB 设备
if livecd_device and device.startswith(livecd_device):
return True
# 排除没有文件系统的设备
if not fstype:
return True
return False
def _parse_size(self, size_str: str) -> int:
"""解析 lsblk 输出的大小字符串"""
if not size_str:
return 0
size_str = size_str.strip()
# 提取数字和单位
try:
if size_str.endswith('T'):
return int(float(size_str[:-1]) * 1024 * 1024 * 1024 * 1024)
elif size_str.endswith('G'):
return int(float(size_str[:-1]) * 1024 * 1024 * 1024)
elif size_str.endswith('M'):
return int(float(size_str[:-1]) * 1024 * 1024)
elif size_str.endswith('K'):
return int(float(size_str[:-1]) * 1024)
else:
return int(size_str)
except ValueError:
return 0
def _fill_usage_info(self, partitions: List[Partition]):
"""填充分区的使用情况信息"""
# 使用 df 命令获取使用情况
stdout, stderr, returncode = run_command(
['df', '-B1'],
use_sudo=True,
sudo_password=self.sudo_password
)
if returncode != 0:
return
# 解析 df 输出
df_info = {}
for line in stdout.strip().split('\n')[1:]: # 跳过标题行
parts = line.split()
if len(parts) >= 6:
device = parts[0]
try:
size = int(parts[1])
used = int(parts[2])
available = int(parts[3])
use_percent = float(parts[4].rstrip('%'))
df_info[device] = {
'size': size,
'used': used,
'available': available,
'use_percent': use_percent
}
except (ValueError, IndexError):
pass
# 填充分区信息
for partition in partitions:
if partition.device in df_info:
info = df_info[partition.device]
partition.size = info['size']
partition.used = info['used']
partition.available = info['available']
partition.use_percent = info['use_percent']
def _detect_livecd_device(self) -> Optional[str]:
"""
检测 LiveCD USB 设备
返回设备名前缀,如 /dev/sdb
"""
# 方法1: 检查 /proc/cmdline
try:
with open('/proc/cmdline', 'r') as f:
cmdline = f.read()
# 查找 boot= 参数
for param in cmdline.split():
if param.startswith('boot='):
boot_device = param.split('=', 1)[1]
# 提取设备名(去掉分区号)
if boot_device.startswith('/dev/'):
return boot_device.rstrip('0123456789')
except Exception:
pass
# 方法2: 检查根文件系统类型
try:
stdout, _, _ = run_command(['findmnt', '-n', '-o', 'SOURCE', '/'])
root_device = stdout.strip()
if root_device:
# 如果根文件系统是 overlay 或 tmpfs,很可能是 LiveCD
stdout2, _, _ = run_command(['findmnt', '-n', '-o', 'FSTYPE', '/'])
fstype = stdout2.strip()
if fstype in ('overlay', 'squashfs', 'tmpfs'):
# 查找 USB 设备
return self._find_usb_device()
except Exception:
pass
# 方法3: 查找标记为 LiveCD 的设备
return self._find_usb_device()
def _find_usb_device(self) -> Optional[str]:
"""查找 USB 设备"""
try:
stdout, _, _ = run_command(
['lsblk', '-J', '-o', 'NAME,TRAN,TYPE'],
use_sudo=True,
sudo_password=self.sudo_password
)
data = json.loads(stdout)
devices = data.get('blockdevices', [])
for device in devices:
if device.get('tran') == 'usb' and device.get('type') == 'disk':
return f"/dev/{device['name']}"
except Exception:
pass
return None
def _get_partitions_from_proc(self) -> List[Partition]:
"""从 /proc/partitions 获取分区信息(备用方案)"""
partitions = []
try:
stdout, _, returncode = run_command(
['cat', '/proc/partitions'],
use_sudo=True,
sudo_password=self.sudo_password
)
if returncode != 0:
return []
livecd_device = self._detect_livecd_device()
for line in stdout.strip().split('\n')[2:]: # 跳过标题行
parts = line.split()
if len(parts) >= 4:
name = parts[3]
# 只处理分区(名称以数字结尾)
if name[-1].isdigit():
device = f"/dev/{name}"
# 排除 LiveCD 设备
if livecd_device and device.startswith(livecd_device):
continue
# 获取文件系统类型
fstype = self._get_fstype(device)
# 排除不需要的文件系统类型
if fstype in self.EXCLUDED_FS_TYPES or not fstype:
continue
# 获取挂载点
mount_point = self._get_mount_point(device)
# 排除 EFI 分区
if mount_point and any(
mount_point.startswith(prefix)
for prefix in self.EXCLUDED_MOUNT_PREFIXES
):
continue
size = int(parts[2]) * 1024 # 转换为字节
partition = Partition(
device=device,
mount_point=mount_point or "",
fstype=fstype,
size=size,
used=0,
available=0,
use_percent=0.0,
is_system=(mount_point == '/')
)
partitions.append(partition)
except Exception as e:
print(f"从 /proc/partitions 获取信息失败: {e}")
# 填充使用情况
self._fill_usage_info(partitions)
return partitions
def _get_fstype(self, device: str) -> str:
"""获取设备的文件系统类型"""
stdout, _, _ = run_command(
['blkid', '-o', 'value', '-s', 'TYPE', device],
use_sudo=True,
sudo_password=self.sudo_password
)
return stdout.strip()
def _get_mount_point(self, device: str) -> str:
"""获取设备的挂载点"""
stdout, _, _ = run_command(
['findmnt', '-n', '-o', 'TARGET', device],
use_sudo=True,
sudo_password=self.sudo_password
)
return stdout.strip()
def mount_partition(self, partition: Partition, mount_point: Optional[str] = None) -> Tuple[bool, str]:
"""
挂载分区到临时目录
参数:
partition: 分区信息
mount_point: 指定挂载点(可选)
返回:
(success, mount_point_or_error_message)
"""
# 如果已经挂载,返回现有挂载点
if partition.mount_point:
return True, partition.mount_point
# 如果已挂载到临时目录
if partition.device in self.mounted_partitions:
return True, self.mounted_partitions[partition.device]
# 创建临时挂载点
if mount_point is None:
mount_point = tempfile.mkdtemp(prefix='log_cleaner_')
# 挂载分区
stdout, stderr, returncode = run_command(
['mount', partition.device, mount_point],
use_sudo=True,
sudo_password=self.sudo_password
)
if returncode == 0:
partition.mount_point = mount_point
self.mounted_partitions[partition.device] = mount_point
return True, mount_point
else:
return False, f"挂载失败: {stderr}"
def unmount_partition(self, partition: Partition) -> Tuple[bool, str]:
"""
卸载分区
参数:
partition: 分区信息
返回:
(success, message)
"""
# 如果是系统挂载点,不卸载
if partition.is_system:
return False, "不能卸载系统分区"
mount_point = partition.mount_point
if not mount_point:
return False, "分区未挂载"
# 如果是临时挂载点,卸载
if partition.device in self.mounted_partitions:
stdout, stderr, returncode = run_command(
['umount', mount_point],
use_sudo=True,
sudo_password=self.sudo_password
)
if returncode == 0:
del self.mounted_partitions[partition.device]
partition.mount_point = ""
# 删除临时目录
try:
os.rmdir(mount_point)
except OSError:
pass
return True, "卸载成功"
else:
return False, f"卸载失败: {stderr}"
return False, "分区不是由本工具挂载的"
def unmount_all(self):
"""卸载所有由本工具挂载的分区"""
for device in list(self.mounted_partitions.keys()):
mount_point = self.mounted_partitions[device]
run_command(
['umount', mount_point],
use_sudo=True,
sudo_password=self.sudo_password
)
try:
os.rmdir(mount_point)
except OSError:
pass
self.mounted_partitions.clear()
def get_partition_display_name(self, partition: Partition) -> str:
"""获取分区的显示名称"""
parts = [partition.device]
if partition.mount_point:
parts.append(f"({partition.mount_point})")
elif partition.label:
parts.append(f"({partition.label})")
if partition.is_system:
parts.append("[系统盘]")
return " ".join(parts)