dwipe 2.0.1__py3-none-any.whl → 2.0.2__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.
- dwipe/DeviceInfo.py +183 -52
- dwipe/DiskWipe.py +495 -180
- dwipe/DrivePreChecker.py +90 -0
- dwipe/FirmwareWipeTask.py +370 -0
- dwipe/LsblkMonitor.py +124 -0
- dwipe/PersistentState.py +26 -8
- dwipe/Prereqs.py +84 -0
- dwipe/StructuredLogger.py +643 -0
- dwipe/ToolManager.py +235 -254
- dwipe/Utils.py +108 -0
- dwipe/VerifyTask.py +410 -0
- dwipe/WipeJob.py +613 -165
- dwipe/WipeTask.py +148 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +14 -9
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/METADATA +69 -33
- dwipe-2.0.2.dist-info/RECORD +21 -0
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-2.0.2.dist-info}/licenses/LICENSE +0 -0
dwipe/DeviceInfo.py
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
"""
|
|
2
2
|
DeviceInfo class for device discovery and information management
|
|
3
3
|
"""
|
|
4
|
+
# pylint: disable=invalid-name,broad-exception-caught
|
|
5
|
+
# pylint: disable=line-too-long,too-many-locals,too-many-branches
|
|
6
|
+
# pylint: disable=too-many-return-statements,too-many-nested-blocks
|
|
7
|
+
# pylint: disable=too-many-statements
|
|
8
|
+
|
|
4
9
|
import os
|
|
5
10
|
import re
|
|
6
11
|
import json
|
|
@@ -8,13 +13,15 @@ import subprocess
|
|
|
8
13
|
import time
|
|
9
14
|
import datetime
|
|
10
15
|
import curses
|
|
16
|
+
import traceback
|
|
11
17
|
from fnmatch import fnmatch
|
|
12
18
|
from types import SimpleNamespace
|
|
19
|
+
from console_window import Theme
|
|
20
|
+
from dataclasses import asdict
|
|
13
21
|
|
|
14
22
|
from .WipeJob import WipeJob
|
|
15
23
|
from .Utils import Utils
|
|
16
|
-
from
|
|
17
|
-
from .PersistentState import PersistentState
|
|
24
|
+
from .DrivePreChecker import DrivePreChecker
|
|
18
25
|
|
|
19
26
|
|
|
20
27
|
class DeviceInfo:
|
|
@@ -23,8 +30,8 @@ class DeviceInfo:
|
|
|
23
30
|
|
|
24
31
|
def __init__(self, opts, persistent_state=None):
|
|
25
32
|
self.opts = opts
|
|
26
|
-
self.
|
|
27
|
-
self.wids =
|
|
33
|
+
self.checker = DrivePreChecker()
|
|
34
|
+
self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
|
|
28
35
|
self.head_str = None
|
|
29
36
|
self.partitions = None
|
|
30
37
|
self.persistent_state = persistent_state
|
|
@@ -49,8 +56,36 @@ class DeviceInfo:
|
|
|
49
56
|
uuid='', # filesystem UUID or PARTUUID
|
|
50
57
|
serial='', # disk serial number (for whole disks)
|
|
51
58
|
port='', # port (for whole disks)
|
|
59
|
+
hw_caps={}, # hw_wipe capabilities (for whole disks)
|
|
60
|
+
hw_nopes={}, # hw reasons cannot do hw wipe
|
|
52
61
|
)
|
|
53
62
|
|
|
63
|
+
def get_hw_capabilities(self, ns):
|
|
64
|
+
"""
|
|
65
|
+
Populates and returns hardware wipe capabilities for a disk.
|
|
66
|
+
Returns cached data if already present.
|
|
67
|
+
"""
|
|
68
|
+
# 1. Check if we already have cached results
|
|
69
|
+
if hasattr(ns, 'hw_caps') and (ns.hw_caps or ns.hw_nopes):
|
|
70
|
+
return ns.hw_caps, ns.hw_nopes
|
|
71
|
+
|
|
72
|
+
# Initialize defaults
|
|
73
|
+
ns.hw_caps, ns.hw_nopes = {}, {}
|
|
74
|
+
|
|
75
|
+
# Skip hardware checks if firmware wipes are disabled
|
|
76
|
+
if not getattr(self.opts, 'firmware_wipes', False):
|
|
77
|
+
return ns.hw_caps, ns.hw_nopes
|
|
78
|
+
|
|
79
|
+
# 4. Perform the actual Probe
|
|
80
|
+
dev_path = f"/dev/{ns.name}"
|
|
81
|
+
if ns.name.startswith('nv'):
|
|
82
|
+
result = self.checker.check_nvme_drive(dev_path)
|
|
83
|
+
elif ns.name.startswith('sd'):
|
|
84
|
+
result = self.checker.check_ata_drive(dev_path)
|
|
85
|
+
# 5. Store Results
|
|
86
|
+
ns.hw_caps, ns.hw_nopes = result.modes, result.issues
|
|
87
|
+
return ns.hw_caps, ns.hw_nopes
|
|
88
|
+
|
|
54
89
|
def _get_port_from_sysfs(self, device_name):
|
|
55
90
|
try:
|
|
56
91
|
sysfs_path = f'/sys/class/block/{device_name}'
|
|
@@ -94,7 +129,6 @@ class DeviceInfo:
|
|
|
94
129
|
except Exception as e:
|
|
95
130
|
# Log exception to file for debugging
|
|
96
131
|
with open('/tmp/dwipe_port_debug.log', 'a', encoding='utf-8') as f:
|
|
97
|
-
import traceback
|
|
98
132
|
f.write(f"Exception in _get_port_from_sysfs({device_name}): {e}\n")
|
|
99
133
|
traceback.print_exc(file=f)
|
|
100
134
|
return ''
|
|
@@ -118,8 +152,15 @@ class DeviceInfo:
|
|
|
118
152
|
rv = f'{get_str(device_name, "model")}'
|
|
119
153
|
return rv.strip()
|
|
120
154
|
|
|
121
|
-
def parse_lsblk(self, dflt, prev_nss=None):
|
|
122
|
-
"""Parse ls_blk for all the goodies we need
|
|
155
|
+
def parse_lsblk(self, dflt, prev_nss=None, lsblk_output=None):
|
|
156
|
+
"""Parse ls_blk for all the goodies we need
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
dflt: Default state for new devices
|
|
160
|
+
prev_nss: Previous device namespaces for merging
|
|
161
|
+
lsblk_output: Optional lsblk JSON output string. If provided, uses this
|
|
162
|
+
instead of running lsblk command. Useful for background monitoring.
|
|
163
|
+
"""
|
|
123
164
|
def eat_one(device):
|
|
124
165
|
entry = self._make_partition_namespace(0, '', '', dflt)
|
|
125
166
|
entry.name = device.get('name', '')
|
|
@@ -187,22 +228,39 @@ class DeviceInfo:
|
|
|
187
228
|
verify_prefix = '✓ '
|
|
188
229
|
elif verify_status == 'fail':
|
|
189
230
|
verify_prefix = '✗ '
|
|
190
|
-
|
|
231
|
+
|
|
232
|
+
# Add error suffix if job failed abnormally
|
|
233
|
+
error_suffix = ''
|
|
234
|
+
abort_reason = getattr(marker, 'abort_reason', None)
|
|
235
|
+
if abort_reason:
|
|
236
|
+
error_suffix = f' Err[{abort_reason}]'
|
|
237
|
+
|
|
238
|
+
entry.marker = f'{verify_prefix}{state} {pct}% {marker.mode} {dt.strftime("%Y/%m/%d %H:%M")}{error_suffix}'
|
|
191
239
|
entry.state = state
|
|
192
240
|
entry.dflt = state # Set dflt so merge logic knows this partition has a marker
|
|
193
241
|
|
|
194
242
|
return entry
|
|
195
243
|
|
|
196
|
-
#
|
|
197
|
-
#
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
244
|
+
# Get lsblk output - either from parameter or by running command
|
|
245
|
+
if lsblk_output: # Non-empty string from background monitor
|
|
246
|
+
# Use provided output string
|
|
247
|
+
try:
|
|
248
|
+
parsed_data = json.loads(lsblk_output)
|
|
249
|
+
except (json.JSONDecodeError, Exception):
|
|
250
|
+
# Invalid JSON - return empty dict
|
|
251
|
+
return {}
|
|
252
|
+
else:
|
|
253
|
+
# Run the `lsblk` command and get its output in JSON format with additional columns
|
|
254
|
+
# Use timeout to prevent UI freeze if lsblk hangs on problematic devices
|
|
255
|
+
try:
|
|
256
|
+
result = subprocess.run(['lsblk', '-J', '--bytes', '-o',
|
|
257
|
+
'NAME,MAJ:MIN,FSTYPE,TYPE,LABEL,PARTLABEL,FSUSE%,SIZE,MOUNTPOINTS,UUID,PARTUUID,SERIAL'],
|
|
258
|
+
stdout=subprocess.PIPE, text=True, check=False, timeout=10.0)
|
|
259
|
+
parsed_data = json.loads(result.stdout)
|
|
260
|
+
except (subprocess.TimeoutExpired, json.JSONDecodeError, Exception): # pylint: disable=broad-exception-caught
|
|
261
|
+
# lsblk hung, returned bad JSON, or other error - return empty dict
|
|
262
|
+
# assemble_partitions will detect this and preserve previous state
|
|
263
|
+
return {}
|
|
206
264
|
entries = {}
|
|
207
265
|
|
|
208
266
|
# Parse each block device and its properties
|
|
@@ -224,13 +282,13 @@ class DeviceInfo:
|
|
|
224
282
|
final_entries = {}
|
|
225
283
|
for name, entry in entries.items():
|
|
226
284
|
final_entries[name] = entry
|
|
227
|
-
|
|
285
|
+
|
|
228
286
|
# Only process top-level physical disks
|
|
229
287
|
if entry.parent is None:
|
|
230
288
|
# Hardware Info Gathering
|
|
231
289
|
entry.model = self._get_device_vendor_model(entry.name)
|
|
232
290
|
entry.port = self._get_port_from_sysfs(entry.name)
|
|
233
|
-
|
|
291
|
+
|
|
234
292
|
# The Split (Superfloppy Case)
|
|
235
293
|
# If it has children, the children already hold the data.
|
|
236
294
|
# If it has NO children but HAS data, we create the '----' child.
|
|
@@ -245,7 +303,7 @@ class DeviceInfo:
|
|
|
245
303
|
|
|
246
304
|
final_entries[v_key] = v_child
|
|
247
305
|
entry.minors.append(v_key)
|
|
248
|
-
|
|
306
|
+
|
|
249
307
|
# Clean the hardware row of data-specific strings
|
|
250
308
|
entry.fstype = entry.model if entry.model else 'DISK'
|
|
251
309
|
entry.label = ''
|
|
@@ -253,11 +311,6 @@ class DeviceInfo:
|
|
|
253
311
|
|
|
254
312
|
entries = final_entries
|
|
255
313
|
|
|
256
|
-
if self.DB:
|
|
257
|
-
print('\n\nDB: --->>> after parse_lsblk:')
|
|
258
|
-
for entry in entries.values():
|
|
259
|
-
print(vars(entry))
|
|
260
|
-
|
|
261
314
|
return entries
|
|
262
315
|
|
|
263
316
|
@staticmethod
|
|
@@ -282,9 +335,9 @@ class DeviceInfo:
|
|
|
282
335
|
|
|
283
336
|
if to == 'STOP' and not state_in(ns.state, job_states):
|
|
284
337
|
return False
|
|
285
|
-
if to == '
|
|
338
|
+
if to == 'Blk' and not state_in(ns.state, list(ready_states) + ['Mnt']):
|
|
286
339
|
return False
|
|
287
|
-
if to == '
|
|
340
|
+
if to == 'Unbl' and ns.state != 'Blk':
|
|
288
341
|
return False
|
|
289
342
|
|
|
290
343
|
if to and fnmatch(to, '*%'):
|
|
@@ -304,7 +357,7 @@ class DeviceInfo:
|
|
|
304
357
|
# Here we set inferences that block starting jobs
|
|
305
358
|
# -- clearing these states will be done on the device refresh
|
|
306
359
|
if parent and state_in(ns.state, inferred_states):
|
|
307
|
-
if parent.state != '
|
|
360
|
+
if parent.state != 'Blk':
|
|
308
361
|
parent.state = ns.state
|
|
309
362
|
if state_in(ns.state, job_states):
|
|
310
363
|
if parent:
|
|
@@ -313,6 +366,14 @@ class DeviceInfo:
|
|
|
313
366
|
minor.state = 'Busy'
|
|
314
367
|
return True
|
|
315
368
|
|
|
369
|
+
@staticmethod
|
|
370
|
+
def clear_inferred_states(nss):
|
|
371
|
+
"""Clear all inferred states (Busy, Mnt) so they can be re-inferred"""
|
|
372
|
+
inferred_states = ('Busy', 'Mnt')
|
|
373
|
+
for ns in nss.values():
|
|
374
|
+
if ns.state in inferred_states:
|
|
375
|
+
ns.state = ns.dflt
|
|
376
|
+
|
|
316
377
|
@staticmethod
|
|
317
378
|
def set_all_states(nss):
|
|
318
379
|
"""Set every state per linkage inferences"""
|
|
@@ -337,6 +398,8 @@ class DeviceInfo:
|
|
|
337
398
|
# Must be disk or partition type
|
|
338
399
|
if ns.type not in ('disk', 'part'):
|
|
339
400
|
continue
|
|
401
|
+
if ns.size_bytes <= 0: # not relevant to wiping
|
|
402
|
+
continue
|
|
340
403
|
|
|
341
404
|
# Must be writable (excludes CD-ROMs, eMMC boot partitions, etc.)
|
|
342
405
|
ro_path = f'/sys/class/block/{name}/ro'
|
|
@@ -361,7 +424,7 @@ class DeviceInfo:
|
|
|
361
424
|
|
|
362
425
|
def compute_field_widths(self, nss):
|
|
363
426
|
"""Compute field widths for display formatting"""
|
|
364
|
-
wids = self.wids
|
|
427
|
+
wids = self.wids
|
|
365
428
|
for ns in nss.values():
|
|
366
429
|
wids.state = max(wids.state, len(ns.state))
|
|
367
430
|
wids.name = max(wids.name, len(ns.name) + 2)
|
|
@@ -370,9 +433,6 @@ class DeviceInfo:
|
|
|
370
433
|
wids.label = max(wids.label, len(ns.label))
|
|
371
434
|
wids.fstype = max(wids.fstype, len(ns.fstype))
|
|
372
435
|
self.head_str = self.get_head_str()
|
|
373
|
-
if self.DB:
|
|
374
|
-
print('\n\nDB: --->>> after compute_field_widths():')
|
|
375
|
-
print(f'self.wids={vars(wids)}')
|
|
376
436
|
|
|
377
437
|
def get_head_str(self):
|
|
378
438
|
"""Generate header string for device list"""
|
|
@@ -457,8 +517,8 @@ class DeviceInfo:
|
|
|
457
517
|
# Check for newly inserted flag first (hot-swapped devices should always show orange)
|
|
458
518
|
if getattr(ns, 'newly_inserted', False):
|
|
459
519
|
# Newly inserted device - orange/bright
|
|
460
|
-
if ns.state in ('Mnt', '
|
|
461
|
-
# Dim the orange for mounted/
|
|
520
|
+
if ns.state in ('Mnt', 'Blk'):
|
|
521
|
+
# Dim the orange for mounted/blocked devices
|
|
462
522
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_DIM
|
|
463
523
|
else:
|
|
464
524
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
|
|
@@ -477,8 +537,8 @@ class DeviceInfo:
|
|
|
477
537
|
elif ns.state == '^':
|
|
478
538
|
# Newly inserted device (hot-swapped) - orange/bright
|
|
479
539
|
attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
|
|
480
|
-
elif ns.state in ('Mnt', '
|
|
481
|
-
# Dim mounted or
|
|
540
|
+
elif ns.state in ('Mnt', 'Blk'):
|
|
541
|
+
# Dim mounted or blocked devices
|
|
482
542
|
attr = curses.A_DIM
|
|
483
543
|
|
|
484
544
|
# Override with red/danger color if verify failed
|
|
@@ -544,12 +604,12 @@ class DeviceInfo:
|
|
|
544
604
|
new_ns.verify_failed_msg = prev_ns.verify_failed_msg
|
|
545
605
|
new_ns.mounts = [prev_ns.verify_failed_msg]
|
|
546
606
|
|
|
547
|
-
if prev_ns.state == '
|
|
548
|
-
new_ns.state = '
|
|
607
|
+
if prev_ns.state == 'Blk':
|
|
608
|
+
new_ns.state = 'Blk'
|
|
549
609
|
elif new_ns.state not in ('s', 'W'):
|
|
550
610
|
new_ns.state = new_ns.dflt
|
|
551
611
|
# Don't copy forward percentage states (like "v96%") - only persistent states
|
|
552
|
-
if prev_ns.state not in ('s', 'W', 'Busy', '
|
|
612
|
+
if prev_ns.state not in ('s', 'W', 'Busy', 'Unbl') and not prev_ns.state.endswith('%'):
|
|
553
613
|
new_ns.state = prev_ns.state # re-infer these
|
|
554
614
|
elif prev_ns.job:
|
|
555
615
|
# unplugged device with job..
|
|
@@ -564,30 +624,101 @@ class DeviceInfo:
|
|
|
564
624
|
new_ns.newly_inserted = True # Mark for orange color even if locked/mounted
|
|
565
625
|
return nss
|
|
566
626
|
|
|
567
|
-
def assemble_partitions(self, prev_nss=None):
|
|
568
|
-
"""Assemble and filter partitions for display
|
|
569
|
-
|
|
627
|
+
def assemble_partitions(self, prev_nss=None, lsblk_output=None):
|
|
628
|
+
"""Assemble and filter partitions for display
|
|
629
|
+
|
|
630
|
+
Args:
|
|
631
|
+
prev_nss: Previous device namespaces for merging
|
|
632
|
+
lsblk_output: Optional lsblk JSON output string from LsblkMonitor
|
|
633
|
+
"""
|
|
634
|
+
nss = self.parse_lsblk(dflt='^' if prev_nss else '-', prev_nss=prev_nss,
|
|
635
|
+
lsblk_output=lsblk_output)
|
|
636
|
+
|
|
637
|
+
# If parse_lsblk failed (returned empty) and we have previous data, keep previous state
|
|
638
|
+
if not nss and prev_nss:
|
|
639
|
+
# lsblk scan failed or returned no devices - preserve previous state
|
|
640
|
+
# This prevents losing devices when lsblk temporarily fails
|
|
641
|
+
# But clear temporary status messages from completed jobs
|
|
642
|
+
for ns in prev_nss.values():
|
|
643
|
+
if not ns.job and ns.mounts:
|
|
644
|
+
# Job finished - clear temporary status messages like "Verified: zeroed"
|
|
645
|
+
ns.mounts = [m for m in ns.mounts if not m.startswith(('Verified:', 'Stopped'))]
|
|
646
|
+
return prev_nss # Return early - don't reprocess
|
|
570
647
|
|
|
571
648
|
nss = self.get_disk_partitions(nss)
|
|
572
649
|
|
|
573
650
|
nss = self.merge_dev_infos(nss, prev_nss)
|
|
574
651
|
|
|
575
|
-
# Apply persistent
|
|
652
|
+
# Apply persistent blocked states
|
|
576
653
|
if self.persistent_state:
|
|
577
654
|
for ns in nss.values():
|
|
578
655
|
# Update last_seen timestamp
|
|
579
656
|
self.persistent_state.update_device_seen(ns)
|
|
580
|
-
# Apply persistent
|
|
657
|
+
# Apply persistent block state
|
|
581
658
|
if self.persistent_state.get_device_locked(ns):
|
|
582
|
-
ns.state = '
|
|
659
|
+
ns.state = 'Blk'
|
|
583
660
|
|
|
661
|
+
# Clear inferred states so they can be re-computed based on current job status
|
|
662
|
+
self.clear_inferred_states(nss)
|
|
584
663
|
self.set_all_states(nss) # set inferred states
|
|
585
664
|
|
|
586
665
|
self.compute_field_widths(nss)
|
|
587
|
-
|
|
588
|
-
if self.DB:
|
|
589
|
-
print('\n\nDB: --->>> after assemble_partitions():')
|
|
590
|
-
for name, ns in nss.items():
|
|
591
|
-
print(f'DB: {name}: {vars(ns)}')
|
|
592
|
-
self.partitions = nss
|
|
593
666
|
return nss
|
|
667
|
+
|
|
668
|
+
@staticmethod
|
|
669
|
+
def dump(parts=None, title='after lsblk'):
|
|
670
|
+
"""Print nicely formatted device information"""
|
|
671
|
+
if not parts:
|
|
672
|
+
return
|
|
673
|
+
|
|
674
|
+
print(f'\n{"=" * 80}')
|
|
675
|
+
print(f'{title}')
|
|
676
|
+
print(f'{"=" * 80}\n')
|
|
677
|
+
|
|
678
|
+
# Separate disks and partitions
|
|
679
|
+
disks = {name: part for name, part in parts.items() if part.type == 'disk'}
|
|
680
|
+
partitions = {name: part for name, part in parts.items() if part.type == 'part'}
|
|
681
|
+
|
|
682
|
+
# Print each disk with its partitions
|
|
683
|
+
for disk_name in sorted(disks.keys()):
|
|
684
|
+
disk = disks[disk_name]
|
|
685
|
+
|
|
686
|
+
# Disk header
|
|
687
|
+
print(f'┌─ {disk.name} ({disk.model or "Unknown Model"})')
|
|
688
|
+
print(f'│ Size: {Utils.human(disk.size_bytes)} Serial: {disk.serial or "N/A"} Port: {disk.port or "N/A"}')
|
|
689
|
+
print(f'│ State: {disk.state} Marker: {disk.marker or "(none)"}')
|
|
690
|
+
|
|
691
|
+
# Hardware capabilities
|
|
692
|
+
if disk.hw_caps:
|
|
693
|
+
caps = ', '.join(disk.hw_caps.keys())
|
|
694
|
+
print(f'│ Hardware: {caps}')
|
|
695
|
+
|
|
696
|
+
# Find and print partitions for this disk
|
|
697
|
+
disk_parts = [(name, part) for name, part in partitions.items()
|
|
698
|
+
if part.parent == disk.name]
|
|
699
|
+
|
|
700
|
+
if disk_parts:
|
|
701
|
+
for i, (part_name, part) in enumerate(sorted(disk_parts)):
|
|
702
|
+
is_last = (i == len(disk_parts) - 1)
|
|
703
|
+
branch = '└─' if is_last else '├─'
|
|
704
|
+
|
|
705
|
+
# Partition info
|
|
706
|
+
label_str = f'"{part.label}"' if part.label else '(no label)'
|
|
707
|
+
fstype_str = part.fstype or '(no filesystem)'
|
|
708
|
+
|
|
709
|
+
print(f'│ {branch} {part.name}: {Utils.human(part.size_bytes)}')
|
|
710
|
+
print(f'│ {" " if is_last else "│ "} Label: {label_str} Type: {fstype_str}')
|
|
711
|
+
print(f'│ {" " if is_last else "│ "} State: {part.state} UUID: {part.uuid or "N/A"}')
|
|
712
|
+
|
|
713
|
+
if part.marker:
|
|
714
|
+
print(f'│ {" " if is_last else "│ "} Marker: {part.marker}')
|
|
715
|
+
|
|
716
|
+
if part.mounts:
|
|
717
|
+
mounts_str = ', '.join(part.mounts)
|
|
718
|
+
print(f'│ {" " if is_last else "│ "} Mounted: {mounts_str}')
|
|
719
|
+
else:
|
|
720
|
+
print(f'│ └─ (no partitions)')
|
|
721
|
+
|
|
722
|
+
print('│')
|
|
723
|
+
|
|
724
|
+
print(f'{"=" * 80}\n')
|