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 ADDED
@@ -0,0 +1,3 @@
1
+ """lsdisk: Cross-distro Linux Storage Explorer."""
2
+
3
+ __version__ = "0.1.0"
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