lsdisk 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lsdisk/__init__.py +3 -0
- lsdisk/analyzers/__init__.py +0 -0
- lsdisk/analyzers/capacity.py +64 -0
- lsdisk/analyzers/filesystems.py +116 -0
- lsdisk/collectors/__init__.py +0 -0
- lsdisk/collectors/block_devices.py +123 -0
- lsdisk/collectors/filesystems.py +89 -0
- lsdisk/inventory.py +79 -0
- lsdisk/main.py +128 -0
- lsdisk/models.py +78 -0
- lsdisk/serialization.py +101 -0
- lsdisk/ui/__init__.py +0 -0
- lsdisk/ui/terminal.py +643 -0
- lsdisk-0.1.1.dist-info/METADATA +173 -0
- lsdisk-0.1.1.dist-info/RECORD +19 -0
- lsdisk-0.1.1.dist-info/WHEEL +5 -0
- lsdisk-0.1.1.dist-info/entry_points.txt +2 -0
- lsdisk-0.1.1.dist-info/licenses/LICENSE +21 -0
- lsdisk-0.1.1.dist-info/top_level.txt +1 -0
lsdisk/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Capacity calculations for lsdisk."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Capacity:
|
|
8
|
+
"""Represent a capacity measurement in bytes."""
|
|
9
|
+
|
|
10
|
+
total_bytes: int
|
|
11
|
+
available_bytes: int
|
|
12
|
+
|
|
13
|
+
def __post_init__(self) -> None:
|
|
14
|
+
if self.total_bytes < 0:
|
|
15
|
+
raise ValueError("Total capacity cannot be negative.")
|
|
16
|
+
|
|
17
|
+
if self.available_bytes < 0:
|
|
18
|
+
raise ValueError("Available space cannot be negative.")
|
|
19
|
+
|
|
20
|
+
if self.available_bytes > self.total_bytes:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
"Available space cannot exceed total capacity."
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def used_bytes(self) -> int:
|
|
27
|
+
"""Calculate used space in bytes."""
|
|
28
|
+
return self.total_bytes - self.available_bytes
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def used_percent(self) -> float:
|
|
32
|
+
"""Calculate the percentage used, from 0 to 100."""
|
|
33
|
+
if self.total_bytes == 0:
|
|
34
|
+
return 0.0
|
|
35
|
+
|
|
36
|
+
return self.used_bytes / self.total_bytes * 100
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def available_percent(self) -> float:
|
|
40
|
+
"""Calculate the percentage available, from 0 to 100."""
|
|
41
|
+
if self.total_bytes == 0:
|
|
42
|
+
return 0.0
|
|
43
|
+
|
|
44
|
+
return self.available_bytes / self.total_bytes * 100
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def format_bytes(size_bytes: int) -> str:
|
|
48
|
+
"""Format bytes using binary units (KiB, MiB, GiB, and TiB)."""
|
|
49
|
+
if size_bytes < 0:
|
|
50
|
+
raise ValueError("Size cannot be negative.")
|
|
51
|
+
|
|
52
|
+
value = float(size_bytes)
|
|
53
|
+
units = ("B", "KiB", "MiB", "GiB", "TiB", "PiB")
|
|
54
|
+
|
|
55
|
+
for unit in units:
|
|
56
|
+
if value < 1024 or unit == units[-1]:
|
|
57
|
+
if unit == "B":
|
|
58
|
+
return f"{int(value)} B"
|
|
59
|
+
|
|
60
|
+
return f"{value:.2f} {unit}"
|
|
61
|
+
|
|
62
|
+
value /= 1024
|
|
63
|
+
|
|
64
|
+
raise AssertionError("Size unit not found.")
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Classification and grouping of mounted filesystems."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
from lsdisk.models import FilesystemUsage
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
VIRTUAL_FILESYSTEMS = {
|
|
9
|
+
"autofs",
|
|
10
|
+
"bpf",
|
|
11
|
+
"cgroup",
|
|
12
|
+
"cgroup2",
|
|
13
|
+
"configfs",
|
|
14
|
+
"debugfs",
|
|
15
|
+
"devpts",
|
|
16
|
+
"devtmpfs",
|
|
17
|
+
"efivarfs",
|
|
18
|
+
"fusectl",
|
|
19
|
+
"hugetlbfs",
|
|
20
|
+
"mqueue",
|
|
21
|
+
"proc",
|
|
22
|
+
"pstore",
|
|
23
|
+
"securityfs",
|
|
24
|
+
"sysfs",
|
|
25
|
+
"tmpfs",
|
|
26
|
+
"tracefs",
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
NETWORK_FILESYSTEMS = {
|
|
30
|
+
"9p",
|
|
31
|
+
"ceph",
|
|
32
|
+
"cifs",
|
|
33
|
+
"glusterfs",
|
|
34
|
+
"nfs",
|
|
35
|
+
"nfs4",
|
|
36
|
+
"smb3",
|
|
37
|
+
"sshfs",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
PERSISTENT_FILESYSTEMS = {
|
|
41
|
+
"btrfs",
|
|
42
|
+
"exfat",
|
|
43
|
+
"ext2",
|
|
44
|
+
"ext3",
|
|
45
|
+
"ext4",
|
|
46
|
+
"f2fs",
|
|
47
|
+
"jfs",
|
|
48
|
+
"ntfs",
|
|
49
|
+
"ntfs3",
|
|
50
|
+
"reiserfs",
|
|
51
|
+
"udf",
|
|
52
|
+
"vfat",
|
|
53
|
+
"xfs",
|
|
54
|
+
"zfs",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class FilesystemGroup:
|
|
60
|
+
"""Represent one filesystem with its associated mountpoints."""
|
|
61
|
+
|
|
62
|
+
representative: FilesystemUsage
|
|
63
|
+
mountpoints: list[str] = field(default_factory=list)
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def total_bytes(self) -> int:
|
|
67
|
+
return self.representative.total_bytes
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def used_bytes(self) -> int:
|
|
71
|
+
return self.representative.used_bytes
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def available_bytes(self) -> int:
|
|
75
|
+
return self.representative.available_bytes
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def classify_filesystem(filesystem: FilesystemUsage) -> str:
|
|
79
|
+
"""Classify a filesystem without hiding unrecognized types."""
|
|
80
|
+
filesystem_type = filesystem.filesystem_type.lower()
|
|
81
|
+
|
|
82
|
+
if filesystem_type in VIRTUAL_FILESYSTEMS:
|
|
83
|
+
return "virtual"
|
|
84
|
+
|
|
85
|
+
if filesystem_type in NETWORK_FILESYSTEMS:
|
|
86
|
+
return "network"
|
|
87
|
+
|
|
88
|
+
if filesystem_type in PERSISTENT_FILESYSTEMS:
|
|
89
|
+
return "persistent"
|
|
90
|
+
|
|
91
|
+
return "unknown"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def group_filesystems(
|
|
95
|
+
filesystems: list[FilesystemUsage],
|
|
96
|
+
) -> list[FilesystemGroup]:
|
|
97
|
+
"""Group mounts that share the same filesystem identity."""
|
|
98
|
+
groups: dict[tuple[str, str, str], FilesystemGroup] = {}
|
|
99
|
+
|
|
100
|
+
for filesystem in filesystems:
|
|
101
|
+
identity = (
|
|
102
|
+
filesystem.device_id or filesystem.source,
|
|
103
|
+
filesystem.filesystem_type,
|
|
104
|
+
filesystem.source,
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
group = groups.get(identity)
|
|
108
|
+
|
|
109
|
+
if group is None:
|
|
110
|
+
group = FilesystemGroup(representative=filesystem)
|
|
111
|
+
groups[identity] = group
|
|
112
|
+
|
|
113
|
+
if filesystem.mountpoint not in group.mountpoints:
|
|
114
|
+
group.mountpoints.append(filesystem.mountpoint)
|
|
115
|
+
|
|
116
|
+
return list(groups.values())
|
|
File without changes
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Discover Linux block devices through sysfs."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from lsdisk.models import BlockDevice
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
SYSFS_BLOCK_PATH = Path("/sys/class/block")
|
|
9
|
+
SECTOR_SIZE_BYTES = 512
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _read_text(path: Path) -> str | None:
|
|
13
|
+
"""Read a sysfs value, returning None when it is unavailable."""
|
|
14
|
+
try:
|
|
15
|
+
return path.read_text(encoding="utf-8").strip()
|
|
16
|
+
except (OSError, UnicodeError):
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _detect_transport(device_path: Path) -> str | None:
|
|
21
|
+
"""Infer the transport from available sysfs information."""
|
|
22
|
+
transport = _read_text(device_path / "device" / "transport")
|
|
23
|
+
if transport:
|
|
24
|
+
return transport
|
|
25
|
+
|
|
26
|
+
resolved = str(device_path.resolve()).lower()
|
|
27
|
+
|
|
28
|
+
indicators = (
|
|
29
|
+
("nvme", "nvme"),
|
|
30
|
+
("ata", "sata"),
|
|
31
|
+
("usb", "usb"),
|
|
32
|
+
("virtio", "virtio"),
|
|
33
|
+
("mmc", "mmc"),
|
|
34
|
+
("iscsi", "iscsi"),
|
|
35
|
+
("sas", "sas"),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
for indicator, transport_name in indicators:
|
|
39
|
+
if indicator in resolved:
|
|
40
|
+
return transport_name
|
|
41
|
+
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_device(entry: Path) -> BlockDevice | None:
|
|
46
|
+
"""Build a device model from a sysfs block-device entry."""
|
|
47
|
+
try:
|
|
48
|
+
resolved = entry.resolve(strict=True)
|
|
49
|
+
except OSError:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
raw_size = _read_text(entry / "size")
|
|
53
|
+
if raw_size is None:
|
|
54
|
+
return None
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
size_bytes = int(raw_size) * SECTOR_SIZE_BYTES
|
|
58
|
+
except ValueError:
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
is_partition = (entry / "partition").exists()
|
|
62
|
+
device_type = "part" if is_partition else "disk"
|
|
63
|
+
|
|
64
|
+
scsi_type = _read_text(entry / "device" / "type")
|
|
65
|
+
if not is_partition and scsi_type == "5":
|
|
66
|
+
device_type = "rom"
|
|
67
|
+
|
|
68
|
+
parent_name = resolved.parent.name if is_partition else None
|
|
69
|
+
|
|
70
|
+
model = _read_text(entry / "device" / "model")
|
|
71
|
+
rotational_value = _read_text(entry / "queue" / "rotational")
|
|
72
|
+
|
|
73
|
+
rotational = None
|
|
74
|
+
if rotational_value in {"0", "1"}:
|
|
75
|
+
rotational = rotational_value == "1"
|
|
76
|
+
|
|
77
|
+
is_virtual = "/devices/virtual/" in str(resolved)
|
|
78
|
+
has_device_link = (entry / "device").exists()
|
|
79
|
+
|
|
80
|
+
physical = (
|
|
81
|
+
device_type == "disk"
|
|
82
|
+
and not is_virtual
|
|
83
|
+
and has_device_link
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
return BlockDevice(
|
|
87
|
+
name=entry.name,
|
|
88
|
+
path=f"/dev/{entry.name}",
|
|
89
|
+
device_type=device_type,
|
|
90
|
+
size_bytes=size_bytes,
|
|
91
|
+
physical=physical,
|
|
92
|
+
parent_name=parent_name,
|
|
93
|
+
model=model,
|
|
94
|
+
transport=_detect_transport(entry),
|
|
95
|
+
rotational=rotational,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def collect_block_devices(
|
|
100
|
+
sysfs_path: Path = SYSFS_BLOCK_PATH,
|
|
101
|
+
) -> list[BlockDevice]:
|
|
102
|
+
"""Discover block devices and return a hierarchy of devices and partitions."""
|
|
103
|
+
if not sysfs_path.is_dir():
|
|
104
|
+
return []
|
|
105
|
+
|
|
106
|
+
discovered: dict[str, BlockDevice] = {}
|
|
107
|
+
|
|
108
|
+
for entry in sorted(sysfs_path.iterdir(), key=lambda item: item.name):
|
|
109
|
+
device = _build_device(entry)
|
|
110
|
+
if device is not None:
|
|
111
|
+
discovered[device.name] = device
|
|
112
|
+
|
|
113
|
+
roots: list[BlockDevice] = []
|
|
114
|
+
|
|
115
|
+
for device in discovered.values():
|
|
116
|
+
parent = discovered.get(device.parent_name or "")
|
|
117
|
+
|
|
118
|
+
if parent is None:
|
|
119
|
+
roots.append(device)
|
|
120
|
+
else:
|
|
121
|
+
parent.children.append(device)
|
|
122
|
+
|
|
123
|
+
return roots
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Discover mounted filesystems using Linux mountinfo and statvfs."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from lsdisk.models import FilesystemUsage
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
MOUNTINFO_PATH = Path("/proc/self/mountinfo")
|
|
11
|
+
_OCTAL_ESCAPE = re.compile(r"\\([0-7]{3})")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _unescape_mountinfo(value: str) -> str:
|
|
15
|
+
"""Decode octal escapes used in mountinfo fields."""
|
|
16
|
+
return _OCTAL_ESCAPE.sub(
|
|
17
|
+
lambda match: chr(int(match.group(1), 8)),
|
|
18
|
+
value,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _parse_mountinfo_line(line: str) -> dict[str, str] | None:
|
|
23
|
+
"""Parse one mountinfo line into normalized fields."""
|
|
24
|
+
try:
|
|
25
|
+
before_separator, after_separator = line.split(" - ", 1)
|
|
26
|
+
left = before_separator.split()
|
|
27
|
+
right = after_separator.split()
|
|
28
|
+
|
|
29
|
+
if len(left) < 6 or len(right) < 3:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
"device_id": left[2],
|
|
34
|
+
"filesystem_root": _unescape_mountinfo(left[3]),
|
|
35
|
+
"mountpoint": _unescape_mountinfo(left[4]),
|
|
36
|
+
"filesystem_type": right[0],
|
|
37
|
+
"source": _unescape_mountinfo(right[1]),
|
|
38
|
+
}
|
|
39
|
+
except (ValueError, IndexError):
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def collect_filesystems(
|
|
44
|
+
mountinfo_path: Path = MOUNTINFO_PATH,
|
|
45
|
+
) -> list[FilesystemUsage]:
|
|
46
|
+
"""Collect capacity statistics for accessible mounted filesystems."""
|
|
47
|
+
try:
|
|
48
|
+
lines = mountinfo_path.read_text(encoding="utf-8").splitlines()
|
|
49
|
+
except (OSError, UnicodeError):
|
|
50
|
+
return []
|
|
51
|
+
|
|
52
|
+
filesystems: list[FilesystemUsage] = []
|
|
53
|
+
|
|
54
|
+
for line in lines:
|
|
55
|
+
mount = _parse_mountinfo_line(line)
|
|
56
|
+
if mount is None:
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
mountpoint = mount["mountpoint"]
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
stats = os.statvfs(mountpoint)
|
|
63
|
+
except OSError:
|
|
64
|
+
# A mount can disappear between reading mountinfo and statvfs.
|
|
65
|
+
continue
|
|
66
|
+
|
|
67
|
+
fragment_size = stats.f_frsize or stats.f_bsize
|
|
68
|
+
total_bytes = stats.f_blocks * fragment_size
|
|
69
|
+
free_bytes = stats.f_bfree * fragment_size
|
|
70
|
+
available_bytes = stats.f_bavail * fragment_size
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
usage = FilesystemUsage(
|
|
74
|
+
mountpoint=mountpoint,
|
|
75
|
+
source=mount["source"],
|
|
76
|
+
filesystem_type=mount["filesystem_type"],
|
|
77
|
+
total_bytes=total_bytes,
|
|
78
|
+
free_bytes=free_bytes,
|
|
79
|
+
available_bytes=available_bytes,
|
|
80
|
+
device_id=mount["device_id"],
|
|
81
|
+
filesystem_root=mount["filesystem_root"],
|
|
82
|
+
)
|
|
83
|
+
except ValueError:
|
|
84
|
+
# Ignore inconsistent statistics rather than reporting bad data.
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
filesystems.append(usage)
|
|
88
|
+
|
|
89
|
+
return filesystems
|
lsdisk/inventory.py
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Build a unified inventory of Linux storage."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
|
|
7
|
+
from lsdisk.analyzers.filesystems import (
|
|
8
|
+
FilesystemGroup,
|
|
9
|
+
group_filesystems,
|
|
10
|
+
)
|
|
11
|
+
from lsdisk.collectors.block_devices import collect_block_devices
|
|
12
|
+
from lsdisk.collectors.filesystems import collect_filesystems
|
|
13
|
+
from lsdisk.models import BlockDevice, FilesystemUsage
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class StorageInventory:
|
|
18
|
+
"""Store discovered devices and filesystem information."""
|
|
19
|
+
|
|
20
|
+
devices: list[BlockDevice]
|
|
21
|
+
filesystems: list[FilesystemUsage]
|
|
22
|
+
filesystem_groups: list[FilesystemGroup]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def walk_devices(
|
|
26
|
+
devices: list[BlockDevice],
|
|
27
|
+
) -> Iterator[BlockDevice]:
|
|
28
|
+
"""Yield devices recursively, including partitions."""
|
|
29
|
+
for device in devices:
|
|
30
|
+
yield device
|
|
31
|
+
yield from walk_devices(device.children)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _canonical_device_path(path: str) -> str | None:
|
|
35
|
+
"""Resolve a device path, including available symlink aliases."""
|
|
36
|
+
if not path.startswith("/dev/"):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
return str(Path(path).resolve(strict=False))
|
|
41
|
+
except (OSError, RuntimeError):
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def collect_storage_inventory() -> StorageInventory:
|
|
46
|
+
"""Collect devices and filesystems and associate their mountpoints."""
|
|
47
|
+
devices = collect_block_devices()
|
|
48
|
+
filesystems = collect_filesystems()
|
|
49
|
+
|
|
50
|
+
devices_by_path: dict[str, BlockDevice] = {}
|
|
51
|
+
|
|
52
|
+
for device in walk_devices(devices):
|
|
53
|
+
devices_by_path[device.path] = device
|
|
54
|
+
canonical_path = _canonical_device_path(device.path)
|
|
55
|
+
|
|
56
|
+
if canonical_path:
|
|
57
|
+
devices_by_path[canonical_path] = device
|
|
58
|
+
|
|
59
|
+
for filesystem in filesystems:
|
|
60
|
+
device = devices_by_path.get(filesystem.source)
|
|
61
|
+
|
|
62
|
+
if device is None:
|
|
63
|
+
canonical_source = _canonical_device_path(filesystem.source)
|
|
64
|
+
if canonical_source:
|
|
65
|
+
device = devices_by_path.get(canonical_source)
|
|
66
|
+
|
|
67
|
+
if device is None:
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
device.filesystem_type = filesystem.filesystem_type
|
|
71
|
+
|
|
72
|
+
if filesystem.mountpoint not in device.mountpoints:
|
|
73
|
+
device.mountpoints.append(filesystem.mountpoint)
|
|
74
|
+
|
|
75
|
+
return StorageInventory(
|
|
76
|
+
devices=devices,
|
|
77
|
+
filesystems=filesystems,
|
|
78
|
+
filesystem_groups=group_filesystems(filesystems),
|
|
79
|
+
)
|
lsdisk/main.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Command-line entry point for lsdisk."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Sequence
|
|
7
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
8
|
+
|
|
9
|
+
from lsdisk import __version__
|
|
10
|
+
from lsdisk.inventory import collect_storage_inventory
|
|
11
|
+
from lsdisk.serialization import inventory_to_dict
|
|
12
|
+
from lsdisk.ui.terminal import (
|
|
13
|
+
create_console,
|
|
14
|
+
render_all,
|
|
15
|
+
render_compact,
|
|
16
|
+
render_simple,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _get_version() -> str:
|
|
21
|
+
"""Return the installed package version when available."""
|
|
22
|
+
try:
|
|
23
|
+
return version("lsdisk")
|
|
24
|
+
except PackageNotFoundError:
|
|
25
|
+
return __version__
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
29
|
+
"""Build the command-line argument parser."""
|
|
30
|
+
parser = argparse.ArgumentParser(
|
|
31
|
+
prog="lsdisk",
|
|
32
|
+
description="Explore Linux storage devices, partitions, and filesystems.",
|
|
33
|
+
epilog=(
|
|
34
|
+
"Examples:\n"
|
|
35
|
+
" lsdisk\n"
|
|
36
|
+
" lsdisk --all\n"
|
|
37
|
+
" lsdisk -T\n"
|
|
38
|
+
" lsdisk --all --json\n"
|
|
39
|
+
" lsdisk --color never"
|
|
40
|
+
),
|
|
41
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
modes = parser.add_mutually_exclusive_group()
|
|
45
|
+
modes.add_argument(
|
|
46
|
+
"--simple",
|
|
47
|
+
action="store_true",
|
|
48
|
+
help="Show the simplified overview (default).",
|
|
49
|
+
)
|
|
50
|
+
modes.add_argument(
|
|
51
|
+
"-a",
|
|
52
|
+
"--all",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Show all devices, mounts, and filesystem statistics.",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
output_modes = parser.add_mutually_exclusive_group()
|
|
58
|
+
output_modes.add_argument(
|
|
59
|
+
"--json",
|
|
60
|
+
action="store_true",
|
|
61
|
+
help="Output machine-readable JSON instead of terminal views.",
|
|
62
|
+
)
|
|
63
|
+
output_modes.add_argument(
|
|
64
|
+
"-T",
|
|
65
|
+
"--table-mode",
|
|
66
|
+
action="store_true",
|
|
67
|
+
help="Use tabular terminal output instead of the compact layout.",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--color",
|
|
72
|
+
choices=("auto", "always", "never"),
|
|
73
|
+
default="auto",
|
|
74
|
+
help="Control terminal colors (default: auto).",
|
|
75
|
+
)
|
|
76
|
+
parser.add_argument(
|
|
77
|
+
"--no-color",
|
|
78
|
+
dest="color",
|
|
79
|
+
action="store_const",
|
|
80
|
+
const="never",
|
|
81
|
+
default=argparse.SUPPRESS,
|
|
82
|
+
help="Disable terminal colors.",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--version",
|
|
86
|
+
action="version",
|
|
87
|
+
version=f"%(prog)s {_get_version()}",
|
|
88
|
+
help="Show the installed version and exit.",
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
return parser
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
95
|
+
"""Run lsdisk."""
|
|
96
|
+
parser = build_parser()
|
|
97
|
+
args = parser.parse_args(argv)
|
|
98
|
+
|
|
99
|
+
try:
|
|
100
|
+
inventory = collect_storage_inventory()
|
|
101
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
102
|
+
print(f"lsdisk: error: {exc}", file=sys.stderr)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
if args.json:
|
|
106
|
+
data = inventory_to_dict(inventory, include_all=args.all)
|
|
107
|
+
print(json.dumps(data, indent=2, ensure_ascii=False))
|
|
108
|
+
return 0
|
|
109
|
+
|
|
110
|
+
console = create_console(args.color)
|
|
111
|
+
|
|
112
|
+
if args.table_mode:
|
|
113
|
+
if args.all:
|
|
114
|
+
render_all(inventory, console)
|
|
115
|
+
else:
|
|
116
|
+
render_simple(inventory, console)
|
|
117
|
+
else:
|
|
118
|
+
render_compact(
|
|
119
|
+
inventory,
|
|
120
|
+
console,
|
|
121
|
+
include_all=args.all,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
raise SystemExit(main())
|
lsdisk/models.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Core data models for lsdisk."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class BlockDevice:
|
|
8
|
+
"""Represents a block device or partition discovered on Linux."""
|
|
9
|
+
|
|
10
|
+
name: str
|
|
11
|
+
path: str
|
|
12
|
+
device_type: str
|
|
13
|
+
size_bytes: int
|
|
14
|
+
physical: bool | None = None
|
|
15
|
+
parent_name: str | None = None
|
|
16
|
+
model: str | None = None
|
|
17
|
+
transport: str | None = None
|
|
18
|
+
rotational: bool | None = None
|
|
19
|
+
filesystem_type: str | None = None
|
|
20
|
+
filesystem_label: str | None = None
|
|
21
|
+
filesystem_uuid: str | None = None
|
|
22
|
+
mountpoints: list[str] = field(default_factory=list)
|
|
23
|
+
children: list["BlockDevice"] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def __post_init__(self) -> None:
|
|
26
|
+
if self.size_bytes < 0:
|
|
27
|
+
raise ValueError("Device size cannot be negative.")
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def is_physical_disk(self) -> bool:
|
|
31
|
+
"""Return whether the collector identified this as a physical disk."""
|
|
32
|
+
return self.device_type == "disk" and self.physical is True
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def is_partition(self) -> bool:
|
|
36
|
+
"""Return whether this represents a partition."""
|
|
37
|
+
return self.device_type == "part"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class FilesystemUsage:
|
|
42
|
+
"""Represents capacity statistics for a mounted filesystem."""
|
|
43
|
+
|
|
44
|
+
mountpoint: str
|
|
45
|
+
source: str
|
|
46
|
+
filesystem_type: str
|
|
47
|
+
total_bytes: int
|
|
48
|
+
free_bytes: int
|
|
49
|
+
available_bytes: int
|
|
50
|
+
device_id: str | None = None
|
|
51
|
+
filesystem_root: str = "/"
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
if min(
|
|
55
|
+
self.total_bytes,
|
|
56
|
+
self.free_bytes,
|
|
57
|
+
self.available_bytes,
|
|
58
|
+
) < 0:
|
|
59
|
+
raise ValueError("Filesystem capacity values cannot be negative.")
|
|
60
|
+
|
|
61
|
+
if self.free_bytes > self.total_bytes:
|
|
62
|
+
raise ValueError("Free space cannot exceed total capacity.")
|
|
63
|
+
|
|
64
|
+
if self.available_bytes > self.free_bytes:
|
|
65
|
+
raise ValueError("Available space cannot exceed free space.")
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def used_bytes(self) -> int:
|
|
69
|
+
"""Return used filesystem space in bytes."""
|
|
70
|
+
return self.total_bytes - self.free_bytes
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def used_percent(self) -> float:
|
|
74
|
+
"""Return the percentage of filesystem space used."""
|
|
75
|
+
if self.total_bytes == 0:
|
|
76
|
+
return 0.0
|
|
77
|
+
|
|
78
|
+
return self.used_bytes / self.total_bytes * 100
|