dwipe 2.0.0__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 CHANGED
@@ -1,18 +1,27 @@
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
+
9
+ import os
10
+ import re
4
11
  import json
5
12
  import subprocess
6
13
  import time
7
14
  import datetime
8
15
  import curses
16
+ import traceback
9
17
  from fnmatch import fnmatch
10
18
  from types import SimpleNamespace
19
+ from console_window import Theme
20
+ from dataclasses import asdict
11
21
 
12
22
  from .WipeJob import WipeJob
13
23
  from .Utils import Utils
14
- from console_window import Theme
15
- from .PersistentState import PersistentState
24
+ from .DrivePreChecker import DrivePreChecker
16
25
 
17
26
 
18
27
  class DeviceInfo:
@@ -21,8 +30,8 @@ class DeviceInfo:
21
30
 
22
31
  def __init__(self, opts, persistent_state=None):
23
32
  self.opts = opts
24
- self.DB = opts.debug
25
- self.wids = None
33
+ self.checker = DrivePreChecker()
34
+ self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
26
35
  self.head_str = None
27
36
  self.partitions = None
28
37
  self.persistent_state = persistent_state
@@ -36,6 +45,7 @@ class DeviceInfo:
36
45
  dflt=dflt, # default run-time state
37
46
  label='', # blkid
38
47
  fstype='', # blkid
48
+ type='', # device type (disk, part)
39
49
  model='', # /sys/class/block/{name}/device/vendor|model
40
50
  size_bytes=size_bytes, # /sys/block/{name}/...
41
51
  marker='', # persistent status
@@ -45,10 +55,86 @@ class DeviceInfo:
45
55
  job=None, # if zap running
46
56
  uuid='', # filesystem UUID or PARTUUID
47
57
  serial='', # disk serial number (for whole disks)
58
+ port='', # port (for whole disks)
59
+ hw_caps={}, # hw_wipe capabilities (for whole disks)
60
+ hw_nopes={}, # hw reasons cannot do hw wipe
48
61
  )
49
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
+
89
+ def _get_port_from_sysfs(self, device_name):
90
+ try:
91
+ sysfs_path = f'/sys/class/block/{device_name}'
92
+ if not os.path.exists(sysfs_path):
93
+ return ''
94
+
95
+ real_path = os.path.realpath(sysfs_path).lower()
96
+
97
+ # 1. USB - Format: USB:1-1.4
98
+ if '/usb' in real_path:
99
+ usb_match = re.search(r'/(\d+-\d+(?:\.\d+)*):', real_path)
100
+ if usb_match:
101
+ return f"USB:{usb_match.group(1)}"
102
+
103
+ # 2. SATA - Format: SATA:1
104
+ elif '/ata' in real_path:
105
+ ata_match = re.search(r'ata(\d+)', real_path)
106
+ if ata_match:
107
+ return f"SATA:{ata_match.group(1)}"
108
+
109
+ # 3. NVMe - Format: PCI:1b.0 (Stripped of 0000:00: noise)
110
+ elif '/nvme' in real_path:
111
+ # This regex ignores the 4-digit domain and the first 2-digit bus
112
+ pci_match = re.search(r'0000:[0-9a-f]{2}:([0-9a-f]{2}\.[0-9a-f])', real_path)
113
+ if pci_match:
114
+ return f"PCI:{pci_match.group(1)}"
115
+ return "NVMe"
116
+
117
+ # 4. MMC/eMMC - Format: MMC:0 or PCI:1a.0 (if PCI-attached)
118
+ elif '/mmc' in real_path:
119
+ # Try to extract mmc host number
120
+ mmc_match = re.search(r'/mmc_host/mmc(\d+)', real_path)
121
+ if mmc_match:
122
+ return f"MMC:{mmc_match.group(1)}"
123
+ # Fallback: try to get PCI address if available
124
+ pci_match = re.search(r'0000:[0-9a-f]{2}:([0-9a-f]{2}\.[0-9a-f])', real_path)
125
+ if pci_match:
126
+ return f"PCI:{pci_match.group(1)}"
127
+ return "MMC"
128
+
129
+ except Exception as e:
130
+ # Log exception to file for debugging
131
+ with open('/tmp/dwipe_port_debug.log', 'a', encoding='utf-8') as f:
132
+ f.write(f"Exception in _get_port_from_sysfs({device_name}): {e}\n")
133
+ traceback.print_exc(file=f)
134
+ return ''
135
+
50
136
  @staticmethod
51
- def get_device_vendor_model(device_name):
137
+ def _get_device_vendor_model(device_name):
52
138
  """Gets the vendor and model for a given device from the /sys/class/block directory.
53
139
  - Args: - device_name: The device name, such as 'sda', 'sdb', etc.
54
140
  - Returns: A string containing the vendor and model information.
@@ -66,8 +152,15 @@ class DeviceInfo:
66
152
  rv = f'{get_str(device_name, "model")}'
67
153
  return rv.strip()
68
154
 
69
- def parse_lsblk(self, dflt, prev_nss=None):
70
- """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
+ """
71
164
  def eat_one(device):
72
165
  entry = self._make_partition_namespace(0, '', '', dflt)
73
166
  entry.name = device.get('name', '')
@@ -135,45 +228,88 @@ class DeviceInfo:
135
228
  verify_prefix = '✓ '
136
229
  elif verify_status == 'fail':
137
230
  verify_prefix = '✗ '
138
- entry.marker = f'{verify_prefix}{state} {pct}% {marker.mode} {dt.strftime("%Y/%m/%d %H:%M")}'
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}'
139
239
  entry.state = state
140
240
  entry.dflt = state # Set dflt so merge logic knows this partition has a marker
141
241
 
142
242
  return entry
143
243
 
144
- # Run the `lsblk` command and get its output in JSON format with additional columns
145
- # Use timeout to prevent UI freeze if lsblk hangs on problematic devices
146
- try:
147
- result = subprocess.run(['lsblk', '-J', '--bytes', '-o',
148
- 'NAME,MAJ:MIN,FSTYPE,TYPE,LABEL,PARTLABEL,FSUSE%,SIZE,MOUNTPOINTS,UUID,PARTUUID,SERIAL'],
149
- stdout=subprocess.PIPE, text=True, check=False, timeout=10.0)
150
- parsed_data = json.loads(result.stdout)
151
- except subprocess.TimeoutExpired:
152
- # lsblk hung - return empty dict to use previous device state
153
- return {}
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 {}
154
264
  entries = {}
155
265
 
156
266
  # Parse each block device and its properties
157
267
  for device in parsed_data['blockdevices']:
158
268
  parent = eat_one(device)
159
- parent.fstype = self.get_device_vendor_model(parent.name)
160
269
  entries[parent.name] = parent
161
270
  for child in device.get('children', []):
162
271
  entry = eat_one(child)
163
272
  entries[entry.name] = entry
164
273
  entry.parent = parent.name
165
274
  parent.minors.append(entry.name)
166
- if not parent.fstype:
167
- parent.fstype = 'DISK'
168
275
  self.disk_majors.add(entry.major)
169
276
  if entry.mounts:
170
277
  entry.state = 'Mnt'
171
278
  parent.state = 'Mnt'
172
279
 
173
- if self.DB:
174
- print('\n\nDB: --->>> after parse_lsblk:')
175
- for entry in entries.values():
176
- print(vars(entry))
280
+
281
+ # Final pass: Identify disks, assign ports, and handle superfloppies
282
+ final_entries = {}
283
+ for name, entry in entries.items():
284
+ final_entries[name] = entry
285
+
286
+ # Only process top-level physical disks
287
+ if entry.parent is None:
288
+ # Hardware Info Gathering
289
+ entry.model = self._get_device_vendor_model(entry.name)
290
+ entry.port = self._get_port_from_sysfs(entry.name)
291
+
292
+ # The Split (Superfloppy Case)
293
+ # If it has children, the children already hold the data.
294
+ # If it has NO children but HAS data, we create the '----' child.
295
+ if not entry.minors and (entry.fstype or entry.label or entry.mounts):
296
+ v_key = f"{name}_data"
297
+ v_child = self._make_partition_namespace(entry.major, name, entry.size_bytes, dflt)
298
+ v_child.name = "----"
299
+ v_child.fstype = entry.fstype
300
+ v_child.label = entry.label
301
+ v_child.mounts = entry.mounts
302
+ v_child.parent = name
303
+
304
+ final_entries[v_key] = v_child
305
+ entry.minors.append(v_key)
306
+
307
+ # Clean the hardware row of data-specific strings
308
+ entry.fstype = entry.model if entry.model else 'DISK'
309
+ entry.label = ''
310
+ entry.mounts = []
311
+
312
+ entries = final_entries
177
313
 
178
314
  return entries
179
315
 
@@ -199,9 +335,9 @@ class DeviceInfo:
199
335
 
200
336
  if to == 'STOP' and not state_in(ns.state, job_states):
201
337
  return False
202
- if to == 'Lock' and not state_in(ns.state, list(ready_states) + ['Mnt']):
338
+ if to == 'Blk' and not state_in(ns.state, list(ready_states) + ['Mnt']):
203
339
  return False
204
- if to == 'Unlk' and ns.state != 'Lock':
340
+ if to == 'Unbl' and ns.state != 'Blk':
205
341
  return False
206
342
 
207
343
  if to and fnmatch(to, '*%'):
@@ -221,7 +357,7 @@ class DeviceInfo:
221
357
  # Here we set inferences that block starting jobs
222
358
  # -- clearing these states will be done on the device refresh
223
359
  if parent and state_in(ns.state, inferred_states):
224
- if parent.state != 'Lock':
360
+ if parent.state != 'Blk':
225
361
  parent.state = ns.state
226
362
  if state_in(ns.state, job_states):
227
363
  if parent:
@@ -230,6 +366,14 @@ class DeviceInfo:
230
366
  minor.state = 'Busy'
231
367
  return True
232
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
+
233
377
  @staticmethod
234
378
  def set_all_states(nss):
235
379
  """Set every state per linkage inferences"""
@@ -237,21 +381,40 @@ class DeviceInfo:
237
381
  DeviceInfo.set_one_state(nss, ns)
238
382
 
239
383
  def get_disk_partitions(self, nss):
240
- """Filter out virtual/pseudo devices (zram, loop, etc.) while keeping physical drives.
241
- Keeps: sd*, nvme*, vd*, hd*, mmcblk* (physical and USB drives)
242
- Excludes: zram*, loop*, dm-*, sr*, ram*
243
- """
244
- # Virtual/pseudo device prefixes to exclude
245
- exclude_prefixes = ('zram', 'loop', 'dm-', 'sr', 'ram')
384
+ """Filter to only wipeable physical storage using positive criteria.
246
385
 
386
+ Keeps devices that:
387
+ - Are type 'disk' or 'part' (from lsblk)
388
+ - Are writable (not read-only)
389
+ - Are real block devices (not virtual)
390
+
391
+ This automatically excludes:
392
+ - Virtual devices (zram, loop, dm-*, etc.)
393
+ - Read-only devices (CD-ROMs, eMMC boot partitions)
394
+ - Special partitions (boot loaders)
395
+ """
247
396
  ok_nss = {}
248
397
  for name, ns in nss.items():
249
398
  # Must be disk or partition type
250
399
  if ns.type not in ('disk', 'part'):
251
400
  continue
401
+ if ns.size_bytes <= 0: # not relevant to wiping
402
+ continue
403
+
404
+ # Must be writable (excludes CD-ROMs, eMMC boot partitions, etc.)
405
+ ro_path = f'/sys/class/block/{name}/ro'
406
+ try:
407
+ with open(ro_path, 'r', encoding='utf-8') as f:
408
+ if f.read().strip() != '0':
409
+ continue # Skip read-only devices
410
+ except (FileNotFoundError, Exception):
411
+ # If we can't read ro flag, skip this device to be safe
412
+ continue
252
413
 
253
- # Exclude virtual/pseudo devices by name prefix
254
- if any(name.startswith(prefix) for prefix in exclude_prefixes):
414
+ # Exclude common virtual device prefixes as a safety net
415
+ # (most should already be filtered by ro check or missing sysfs)
416
+ virtual_prefixes = ('zram', 'loop', 'dm-', 'ram')
417
+ if any(name.startswith(prefix) for prefix in virtual_prefixes):
255
418
  continue
256
419
 
257
420
  # Include this device
@@ -261,7 +424,7 @@ class DeviceInfo:
261
424
 
262
425
  def compute_field_widths(self, nss):
263
426
  """Compute field widths for display formatting"""
264
- wids = self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
427
+ wids = self.wids
265
428
  for ns in nss.values():
266
429
  wids.state = max(wids.state, len(ns.state))
267
430
  wids.name = max(wids.name, len(ns.name) + 2)
@@ -270,9 +433,6 @@ class DeviceInfo:
270
433
  wids.label = max(wids.label, len(ns.label))
271
434
  wids.fstype = max(wids.fstype, len(ns.fstype))
272
435
  self.head_str = self.get_head_str()
273
- if self.DB:
274
- print('\n\nDB: --->>> after compute_field_widths():')
275
- print(f'self.wids={vars(wids)}')
276
436
 
277
437
  def get_head_str(self):
278
438
  """Generate header string for device list"""
@@ -357,8 +517,8 @@ class DeviceInfo:
357
517
  # Check for newly inserted flag first (hot-swapped devices should always show orange)
358
518
  if getattr(ns, 'newly_inserted', False):
359
519
  # Newly inserted device - orange/bright
360
- if ns.state in ('Mnt', 'Lock'):
361
- # Dim the orange for mounted/locked devices
520
+ if ns.state in ('Mnt', 'Blk'):
521
+ # Dim the orange for mounted/blocked devices
362
522
  attr = curses.color_pair(Theme.HOTSWAP) | curses.A_DIM
363
523
  else:
364
524
  attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
@@ -377,8 +537,8 @@ class DeviceInfo:
377
537
  elif ns.state == '^':
378
538
  # Newly inserted device (hot-swapped) - orange/bright
379
539
  attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
380
- elif ns.state in ('Mnt', 'Lock'):
381
- # Dim mounted or locked devices
540
+ elif ns.state in ('Mnt', 'Blk'):
541
+ # Dim mounted or blocked devices
382
542
  attr = curses.A_DIM
383
543
 
384
544
  # Override with red/danger color if verify failed
@@ -405,6 +565,7 @@ class DeviceInfo:
405
565
  if new_ns:
406
566
  if prev_ns.job:
407
567
  new_ns.job = prev_ns.job
568
+ # Note: Do NOT preserve port - use fresh value from current scan
408
569
  new_ns.dflt = prev_ns.dflt
409
570
  # Preserve the "wiped this session" flag
410
571
  if hasattr(prev_ns, 'wiped_this_session'):
@@ -443,12 +604,12 @@ class DeviceInfo:
443
604
  new_ns.verify_failed_msg = prev_ns.verify_failed_msg
444
605
  new_ns.mounts = [prev_ns.verify_failed_msg]
445
606
 
446
- if prev_ns.state == 'Lock':
447
- new_ns.state = 'Lock'
607
+ if prev_ns.state == 'Blk':
608
+ new_ns.state = 'Blk'
448
609
  elif new_ns.state not in ('s', 'W'):
449
610
  new_ns.state = new_ns.dflt
450
611
  # Don't copy forward percentage states (like "v96%") - only persistent states
451
- if prev_ns.state not in ('s', 'W', 'Busy', 'Unlk') and not prev_ns.state.endswith('%'):
612
+ if prev_ns.state not in ('s', 'W', 'Busy', 'Unbl') and not prev_ns.state.endswith('%'):
452
613
  new_ns.state = prev_ns.state # re-infer these
453
614
  elif prev_ns.job:
454
615
  # unplugged device with job..
@@ -463,30 +624,101 @@ class DeviceInfo:
463
624
  new_ns.newly_inserted = True # Mark for orange color even if locked/mounted
464
625
  return nss
465
626
 
466
- def assemble_partitions(self, prev_nss=None):
467
- """Assemble and filter partitions for display"""
468
- nss = self.parse_lsblk(dflt='^' if prev_nss else '-', prev_nss=prev_nss)
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
469
647
 
470
648
  nss = self.get_disk_partitions(nss)
471
649
 
472
650
  nss = self.merge_dev_infos(nss, prev_nss)
473
651
 
474
- # Apply persistent locked states
652
+ # Apply persistent blocked states
475
653
  if self.persistent_state:
476
654
  for ns in nss.values():
477
655
  # Update last_seen timestamp
478
656
  self.persistent_state.update_device_seen(ns)
479
- # Apply persistent lock state
657
+ # Apply persistent block state
480
658
  if self.persistent_state.get_device_locked(ns):
481
- ns.state = 'Lock'
659
+ ns.state = 'Blk'
482
660
 
661
+ # Clear inferred states so they can be re-computed based on current job status
662
+ self.clear_inferred_states(nss)
483
663
  self.set_all_states(nss) # set inferred states
484
664
 
485
665
  self.compute_field_widths(nss)
486
-
487
- if self.DB:
488
- print('\n\nDB: --->>> after assemble_partitions():')
489
- for name, ns in nss.items():
490
- print(f'DB: {name}: {vars(ns)}')
491
- self.partitions = nss
492
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')