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/ui/terminal.py ADDED
@@ -0,0 +1,643 @@
1
+ """Rich-based terminal interface for lsdisk."""
2
+
3
+ from rich.console import Console
4
+ from pathlib import Path
5
+
6
+ from rich.panel import Panel
7
+ from rich.text import Text
8
+ from rich.tree import Tree
9
+ from rich.table import Table
10
+
11
+ from lsdisk.analyzers.capacity import format_bytes
12
+ from lsdisk.analyzers.filesystems import classify_filesystem
13
+ from lsdisk.inventory import StorageInventory, walk_devices
14
+ from lsdisk.models import BlockDevice
15
+
16
+
17
+ SUMMARY_CATEGORIES = {"persistent", "network"}
18
+
19
+
20
+ def create_console(color: str = "auto") -> Console:
21
+ """Create a terminal console with the requested color policy."""
22
+ if color == "always":
23
+ return Console(force_terminal=True)
24
+
25
+ if color == "never":
26
+ return Console(force_terminal=False, no_color=True)
27
+
28
+ return Console()
29
+
30
+
31
+ def _device_type(device: BlockDevice) -> str:
32
+ """Return a human-readable device classification."""
33
+ if device.device_type == "disk":
34
+ if device.physical is True:
35
+ return "Physical disk"
36
+ if device.physical is False:
37
+ return "Virtual or logical disk"
38
+ return "Unclassified disk"
39
+
40
+ if device.device_type == "part":
41
+ return "Partition"
42
+
43
+ if device.device_type == "rom":
44
+ return "Optical drive"
45
+
46
+ return device.device_type
47
+
48
+
49
+ def _device_table(*, detailed: bool = False) -> Table:
50
+ table = Table(title="Block Devices")
51
+ table.add_column("Device", style="cyan")
52
+ table.add_column("Type")
53
+ table.add_column("Capacity", justify="right")
54
+ table.add_column("Filesystem")
55
+ table.add_column("Mountpoints")
56
+
57
+ if detailed:
58
+ table.add_column("Model")
59
+ table.add_column("Transport")
60
+ table.add_column("Media")
61
+
62
+ return table
63
+
64
+
65
+ def _add_device_rows(
66
+ table: Table,
67
+ devices: list[BlockDevice],
68
+ *,
69
+ detailed: bool = False,
70
+ depth: int = 0,
71
+ ) -> None:
72
+ """Add a recursive device hierarchy to a table."""
73
+ for device in devices:
74
+ name = f"{' ' * depth}{device.name}"
75
+ mountpoints = ", ".join(device.mountpoints) or "Not mounted"
76
+
77
+ row = [
78
+ name,
79
+ _device_type(device),
80
+ format_bytes(device.size_bytes),
81
+ device.filesystem_type or "Unknown",
82
+ mountpoints,
83
+ ]
84
+
85
+ if detailed:
86
+ if device.rotational is True:
87
+ media = "Rotational"
88
+ elif device.rotational is False:
89
+ media = "Non-rotational"
90
+ else:
91
+ media = "Unknown"
92
+
93
+ row.extend([
94
+ device.model or "Unknown",
95
+ device.transport or "Unknown",
96
+ media,
97
+ ])
98
+
99
+ table.add_row(*row)
100
+
101
+ _add_device_rows(
102
+ table,
103
+ device.children,
104
+ detailed=detailed,
105
+ depth=depth + 1,
106
+ )
107
+
108
+
109
+ def _filesystem_group_table() -> Table:
110
+ table = Table(title="Filesystem Capacity Summary")
111
+ table.add_column("Filesystem", style="cyan")
112
+ table.add_column("Category")
113
+ table.add_column("Source")
114
+ table.add_column("Total", justify="right")
115
+ table.add_column("Used", justify="right")
116
+ table.add_column("Available", justify="right")
117
+ table.add_column("Used %", justify="right")
118
+ table.add_column("Mountpoints")
119
+ return table
120
+
121
+
122
+ def _mount_table() -> Table:
123
+ table = Table(title="Mounted Filesystems")
124
+ table.add_column("Mountpoint", style="cyan")
125
+ table.add_column("Filesystem")
126
+ table.add_column("Category")
127
+ table.add_column("Source")
128
+ table.add_column("Total", justify="right")
129
+ table.add_column("Used", justify="right")
130
+ table.add_column("Available", justify="right")
131
+ table.add_column("Used %", justify="right")
132
+ table.add_column("Device ID")
133
+ table.add_column("Filesystem Root")
134
+ return table
135
+
136
+
137
+ def _add_group_rows(
138
+ table: Table,
139
+ inventory: StorageInventory,
140
+ *,
141
+ include_all: bool,
142
+ ) -> int:
143
+ """Add unique filesystem groups without repeating capacity."""
144
+ count = 0
145
+
146
+ for group in inventory.filesystem_groups:
147
+ filesystem = group.representative
148
+ category = classify_filesystem(filesystem)
149
+
150
+ if not include_all and category not in SUMMARY_CATEGORIES:
151
+ continue
152
+
153
+ table.add_row(
154
+ filesystem.filesystem_type,
155
+ category.title(),
156
+ filesystem.source,
157
+ format_bytes(group.total_bytes),
158
+ format_bytes(group.used_bytes),
159
+ format_bytes(group.available_bytes),
160
+ f"{filesystem.used_percent:.1f}%",
161
+ ", ".join(group.mountpoints),
162
+ )
163
+ count += 1
164
+
165
+ return count
166
+
167
+
168
+ def render_simple(
169
+ inventory: StorageInventory,
170
+ console: Console | None = None,
171
+ ) -> None:
172
+ """Render the default storage overview."""
173
+ console = console or Console()
174
+
175
+ console.print(
176
+ Panel(
177
+ "[bold cyan]lsdisk[/bold cyan]\n"
178
+ "Cross-distro Linux Storage Explorer",
179
+ title="Storage Overview",
180
+ border_style="cyan",
181
+ )
182
+ )
183
+
184
+ physical_disks = [
185
+ device for device in inventory.devices
186
+ if device.is_physical_disk
187
+ ]
188
+
189
+ if physical_disks:
190
+ table = _device_table()
191
+ _add_device_rows(table, physical_disks)
192
+ console.print(table)
193
+ else:
194
+ console.print("[yellow]No physical disks detected.[/yellow]")
195
+
196
+ filesystems = _filesystem_group_table()
197
+ count = _add_group_rows(
198
+ filesystems,
199
+ inventory,
200
+ include_all=False,
201
+ )
202
+
203
+ if count:
204
+ console.print(filesystems)
205
+ else:
206
+ console.print(
207
+ "[yellow]No persistent or network filesystems detected.[/yellow]"
208
+ )
209
+
210
+
211
+ def render_all(
212
+ inventory: StorageInventory,
213
+ console: Console | None = None,
214
+ ) -> None:
215
+ """Render the complete storage inventory."""
216
+ console = console or Console()
217
+
218
+ console.print(
219
+ Panel(
220
+ "[bold cyan]lsdisk[/bold cyan]\n"
221
+ "Complete storage inventory",
222
+ title="Storage Overview",
223
+ border_style="cyan",
224
+ )
225
+ )
226
+
227
+ devices = _device_table(detailed=True)
228
+ _add_device_rows(
229
+ devices,
230
+ inventory.devices,
231
+ detailed=True,
232
+ )
233
+ console.print(devices)
234
+
235
+ mounts = _mount_table()
236
+ for filesystem in inventory.filesystems:
237
+ mounts.add_row(
238
+ filesystem.mountpoint,
239
+ filesystem.filesystem_type,
240
+ classify_filesystem(filesystem).title(),
241
+ filesystem.source,
242
+ format_bytes(filesystem.total_bytes),
243
+ format_bytes(filesystem.used_bytes),
244
+ format_bytes(filesystem.available_bytes),
245
+ f"{filesystem.used_percent:.1f}%",
246
+ filesystem.device_id or "Unknown",
247
+ filesystem.filesystem_root,
248
+ )
249
+ console.print(mounts)
250
+
251
+ groups = _filesystem_group_table()
252
+ _add_group_rows(groups, inventory, include_all=True)
253
+ console.print(groups)
254
+
255
+ console.print(
256
+ f"\nDiscovered mountpoints: {len(inventory.filesystems)}"
257
+ f" | Unique filesystem groups: {len(inventory.filesystem_groups)}"
258
+ )
259
+
260
+
261
+ def _source_key(source: str) -> str:
262
+ """Normalize a local device path for matching."""
263
+ if not source.startswith("/dev/"):
264
+ return source
265
+
266
+ try:
267
+ return str(Path(source).resolve())
268
+ except (OSError, RuntimeError):
269
+ return source
270
+
271
+
272
+ def _percentage(used: int, total: int) -> str:
273
+ """Format a percentage without hiding small nonzero values."""
274
+ if total <= 0:
275
+ return "N/A"
276
+
277
+ percent = used / total * 100
278
+
279
+ if 0 < percent < 0.1:
280
+ return "<0.1%"
281
+
282
+ return f"{percent:.1f}%"
283
+
284
+
285
+ def _usage_color(percentage: float) -> str:
286
+ """Select a color based on filesystem utilization."""
287
+ if percentage >= 90:
288
+ return "red"
289
+ if percentage >= 70:
290
+ return "yellow"
291
+ return "green"
292
+
293
+
294
+ def _local_filesystem_groups(
295
+ inventory: StorageInventory,
296
+ ):
297
+ """Return mounted local persistent filesystems."""
298
+ return [
299
+ group
300
+ for group in inventory.filesystem_groups
301
+ if (
302
+ classify_filesystem(group.representative) == "persistent"
303
+ and group.representative.source.startswith("/dev/")
304
+ )
305
+ ]
306
+
307
+
308
+ def _groups_by_source(inventory: StorageInventory):
309
+ """Index local filesystem groups by their source device."""
310
+ result = {}
311
+
312
+ for group in _local_filesystem_groups(inventory):
313
+ key = _source_key(group.representative.source)
314
+ result.setdefault(key, []).append(group)
315
+
316
+ return result
317
+
318
+
319
+ def _usage_bar(used: int, total: int, width: int = 24) -> Text:
320
+ """Create a compact text-based utilization bar."""
321
+ bar = Text()
322
+
323
+ if total <= 0:
324
+ bar.append("Usage data unavailable", style="dim")
325
+ return bar
326
+
327
+ percentage = min(100.0, max(0.0, used / total * 100))
328
+ filled = round(width * percentage / 100)
329
+
330
+ bar.append("█" * filled, style=_usage_color(percentage))
331
+ bar.append("░" * (width - filled), style="dim")
332
+ bar.append(
333
+ f" {percentage:.1f}%",
334
+ style=_usage_color(percentage),
335
+ )
336
+ return bar
337
+
338
+
339
+ def _add_compact_device(
340
+ parent: Tree,
341
+ device: BlockDevice,
342
+ groups_by_source: dict,
343
+ *,
344
+ detailed: bool,
345
+ ) -> None:
346
+ """Add a device and its filesystem information to a compact tree."""
347
+ label = Text()
348
+ label.append(device.name, style="bold cyan")
349
+ label.append(f" {format_bytes(device.size_bytes)}")
350
+
351
+ if device.device_type == "disk":
352
+ if device.physical:
353
+ label.append(" Physical disk", style="green")
354
+ elif device.physical is False:
355
+ label.append(" Virtual/logical disk", style="dim")
356
+ else:
357
+ label.append(" Disk type unverified", style="yellow")
358
+
359
+ if device.rotational is True:
360
+ label.append(" HDD", style="yellow")
361
+ elif device.rotational is False and device.physical:
362
+ label.append(" SSD/Flash", style="green")
363
+
364
+ if device.model:
365
+ label.append(f" {device.model}", style="dim")
366
+
367
+ if detailed and device.transport:
368
+ label.append(f" [{device.transport}]", style="dim")
369
+
370
+ elif device.device_type == "part":
371
+ label.append(
372
+ f" {device.filesystem_type or 'Unknown filesystem'}",
373
+ style="cyan",
374
+ )
375
+
376
+ elif device.device_type == "rom":
377
+ label.append(" Optical drive", style="dim")
378
+
379
+ node = parent.add(label)
380
+
381
+ if device.is_partition:
382
+ groups = groups_by_source.get(_source_key(device.path), [])
383
+
384
+ if groups:
385
+ for group in groups:
386
+ filesystem = group.representative
387
+ percentage = (
388
+ group.used_bytes / group.total_bytes * 100
389
+ if group.total_bytes
390
+ else 0.0
391
+ )
392
+
393
+ usage = Text("Usage: ")
394
+ usage.append(
395
+ f"{format_bytes(group.used_bytes)} used",
396
+ style=_usage_color(percentage),
397
+ )
398
+ #usage.append(
399
+ # f" ({_percentage(group.used_bytes, group.total_bytes)})"
400
+ #)
401
+ usage.append(
402
+ f" | {format_bytes(group.available_bytes)} available"
403
+ )
404
+ node.add(usage)
405
+ node.add(
406
+ _usage_bar(
407
+ group.used_bytes,
408
+ group.total_bytes,
409
+ width=24,
410
+ )
411
+ )
412
+
413
+ mounts = ", ".join(group.mountpoints) or "Not mounted"
414
+ mount_text = Text("Mounts: ", style="dim")
415
+ mount_text.append(mounts)
416
+ node.add(mount_text)
417
+
418
+ else:
419
+ status = Text("Usage unavailable", style="dim")
420
+ mounts = ", ".join(device.mountpoints) or "Not mounted"
421
+ status.append(f" | {mounts}")
422
+ node.add(status)
423
+
424
+ for child in device.children:
425
+ _add_compact_device(
426
+ node,
427
+ child,
428
+ groups_by_source,
429
+ detailed=detailed,
430
+ )
431
+
432
+
433
+ def _render_compact_summary(
434
+ inventory: StorageInventory,
435
+ console: Console,
436
+ ) -> None:
437
+ """Display hardware counts and unique local filesystem capacity."""
438
+ devices = list(walk_devices(inventory.devices))
439
+ physical_disks = [
440
+ device for device in devices if device.is_physical_disk
441
+ ]
442
+ partitions = [device for device in devices if device.is_partition]
443
+
444
+ physical_capacity = sum(
445
+ device.size_bytes for device in physical_disks
446
+ )
447
+
448
+ groups = _local_filesystem_groups(inventory)
449
+ total = sum(group.total_bytes for group in groups)
450
+ used = sum(group.used_bytes for group in groups)
451
+ available = sum(group.available_bytes for group in groups)
452
+
453
+ summary = Text()
454
+ summary.append(f"Physical disks: {len(physical_disks)}")
455
+ summary.append(f" Partitions: {len(partitions)}")
456
+ summary.append(
457
+ f" Physical capacity: {format_bytes(physical_capacity)}\n"
458
+ )
459
+
460
+ if total > 0:
461
+ summary.append(
462
+ f"Mounted local filesystems: {format_bytes(total)} total\n"
463
+ )
464
+ summary.append("Used: ", style="dim")
465
+ summary.append(
466
+ f"{format_bytes(used)}", # ({_percentage(used, total)})",
467
+ style=_usage_color(used / total * 100),
468
+ )
469
+ summary.append(
470
+ f" Available to users: {format_bytes(available)}\n"
471
+ )
472
+ summary.append_text(_usage_bar(used, total))
473
+ else:
474
+ summary.append(
475
+ "Mounted local filesystem usage is unavailable.",
476
+ style="dim",
477
+ )
478
+
479
+ console.print(
480
+ Panel(
481
+ summary,
482
+ title="Storage Summary",
483
+ border_style="cyan",
484
+ )
485
+ )
486
+
487
+
488
+ def _render_compact_mounts(
489
+ inventory: StorageInventory,
490
+ console: Console,
491
+ ) -> None:
492
+ """Display filesystem groups with their mountpoints underneath."""
493
+ root = Tree(
494
+ Text("Filesystems and Mountpoints", style="bold cyan"),
495
+ guide_style="dim",
496
+ )
497
+
498
+ category_order = {
499
+ "persistent": 0,
500
+ "network": 1,
501
+ "virtual": 2,
502
+ "unknown": 3,
503
+ }
504
+
505
+ groups = sorted(
506
+ inventory.filesystem_groups,
507
+ key=lambda group: (
508
+ category_order.get(
509
+ classify_filesystem(group.representative), 4
510
+ ),
511
+ group.representative.filesystem_type,
512
+ group.representative.source,
513
+ ),
514
+ )
515
+
516
+ for group in groups:
517
+ filesystem = group.representative
518
+ category = classify_filesystem(filesystem)
519
+
520
+ label = Text()
521
+ label.append(filesystem.filesystem_type, style="bold cyan")
522
+ label.append(" ")
523
+ label.append(filesystem.source, style="bold")
524
+ label.append(f" [{category}] ", style="dim")
525
+ label.append(f" {format_bytes(group.total_bytes)}")
526
+
527
+ node = root.add(label)
528
+
529
+ if group.total_bytes > 0:
530
+ percentage = group.used_bytes / group.total_bytes * 100
531
+
532
+ usage = Text()
533
+ usage.append("Used: ", style="dim")
534
+ usage.append(
535
+ format_bytes(group.used_bytes),
536
+ style=_usage_color(percentage),
537
+ )
538
+ usage.append(f" ({_percentage(group.used_bytes, group.total_bytes)})")
539
+ usage.append(" | Available: ")
540
+ usage.append(
541
+ format_bytes(group.available_bytes),
542
+ style="green",
543
+ )
544
+ node.add(usage)
545
+ node.add(_usage_bar(group.used_bytes, group.total_bytes, width=24))
546
+ else:
547
+ node.add(
548
+ Text(
549
+ "Capacity statistics unavailable or not applicable",
550
+ style="dim",
551
+ )
552
+ )
553
+
554
+ identity = (
555
+ filesystem.device_id or filesystem.source,
556
+ filesystem.filesystem_type,
557
+ filesystem.source,
558
+ )
559
+
560
+ mounts = [
561
+ item
562
+ for item in inventory.filesystems
563
+ if (
564
+ (
565
+ item.device_id or item.source,
566
+ item.filesystem_type,
567
+ item.source,
568
+ ) == identity
569
+ )
570
+ ]
571
+
572
+ for item in sorted(mounts, key=lambda mount: mount.mountpoint):
573
+ mount_label = Text()
574
+ mount_label.append(item.mountpoint, style="cyan")
575
+
576
+ if item.filesystem_root != "/":
577
+ mount_label.append(
578
+ f" (subvolume root: {item.filesystem_root})",
579
+ style="dim",
580
+ )
581
+
582
+ node.add(mount_label)
583
+
584
+ console.print(
585
+ Panel(
586
+ root,
587
+ title="Filesystem Details",
588
+ subtitle="Capacity is shown once per filesystem group",
589
+ border_style="blue",
590
+ )
591
+ )
592
+
593
+ def render_compact(
594
+ inventory: StorageInventory,
595
+ console: Console | None = None,
596
+ *,
597
+ include_all: bool = False,
598
+ ) -> None:
599
+ """Render the default compact view or the complete compact inventory."""
600
+ console = console or Console()
601
+
602
+ console.print(
603
+ Panel(
604
+ Text.assemble(
605
+ ("lsdisk", "bold cyan"),
606
+ (" Linux Storage Explorer", "white"),
607
+ ),
608
+ border_style="cyan",
609
+ )
610
+ )
611
+
612
+ _render_compact_summary(inventory, console)
613
+
614
+ groups_by_source = _groups_by_source(inventory)
615
+
616
+ if include_all:
617
+ visible_devices = inventory.devices
618
+ title = "All Block Devices"
619
+ else:
620
+ visible_devices = [
621
+ device
622
+ for device in inventory.devices
623
+ if device.is_physical_disk
624
+ ]
625
+ title = "Physical Storage"
626
+
627
+ root = Tree(Text(title, style="bold cyan"), guide_style="dim")
628
+
629
+ for device in visible_devices:
630
+ _add_compact_device(
631
+ root,
632
+ device,
633
+ groups_by_source,
634
+ detailed=include_all,
635
+ )
636
+
637
+ if visible_devices:
638
+ console.print(Panel(root, border_style="blue"))
639
+ else:
640
+ console.print("[yellow]No matching block devices detected.[/yellow]")
641
+
642
+ if include_all:
643
+ _render_compact_mounts(inventory, console)