dwipe 1.0.5__py3-none-any.whl → 2.0.0__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 ADDED
@@ -0,0 +1,492 @@
1
+ """
2
+ DeviceInfo class for device discovery and information management
3
+ """
4
+ import json
5
+ import subprocess
6
+ import time
7
+ import datetime
8
+ import curses
9
+ from fnmatch import fnmatch
10
+ from types import SimpleNamespace
11
+
12
+ from .WipeJob import WipeJob
13
+ from .Utils import Utils
14
+ from console_window import Theme
15
+ from .PersistentState import PersistentState
16
+
17
+
18
+ class DeviceInfo:
19
+ """Class to dig out the info we want from the system."""
20
+ disk_majors = set() # major devices that are disks
21
+
22
+ def __init__(self, opts, persistent_state=None):
23
+ self.opts = opts
24
+ self.DB = opts.debug
25
+ self.wids = None
26
+ self.head_str = None
27
+ self.partitions = None
28
+ self.persistent_state = persistent_state
29
+
30
+ @staticmethod
31
+ def _make_partition_namespace(major, name, size_bytes, dflt):
32
+ return SimpleNamespace(name=name, # /proc/partitions
33
+ major=major, # /proc/partitions
34
+ parent=None, # a partition
35
+ state=dflt, # run-time state
36
+ dflt=dflt, # default run-time state
37
+ label='', # blkid
38
+ fstype='', # blkid
39
+ model='', # /sys/class/block/{name}/device/vendor|model
40
+ size_bytes=size_bytes, # /sys/block/{name}/...
41
+ marker='', # persistent status
42
+ marker_checked=False, # True if we've read the marker once
43
+ mounts=[], # /proc/mounts
44
+ minors=[],
45
+ job=None, # if zap running
46
+ uuid='', # filesystem UUID or PARTUUID
47
+ serial='', # disk serial number (for whole disks)
48
+ )
49
+
50
+ @staticmethod
51
+ def get_device_vendor_model(device_name):
52
+ """Gets the vendor and model for a given device from the /sys/class/block directory.
53
+ - Args: - device_name: The device name, such as 'sda', 'sdb', etc.
54
+ - Returns: A string containing the vendor and model information.
55
+ """
56
+ def get_str(device_name, suffix):
57
+ try:
58
+ rv = ''
59
+ fullpath = f'/sys/class/block/{device_name}/device/{suffix}'
60
+ with open(fullpath, 'r', encoding='utf-8') as f: # Read information
61
+ rv = f.read().strip()
62
+ except (FileNotFoundError, Exception):
63
+ pass
64
+ return rv
65
+
66
+ rv = f'{get_str(device_name, "model")}'
67
+ return rv.strip()
68
+
69
+ def parse_lsblk(self, dflt, prev_nss=None):
70
+ """Parse ls_blk for all the goodies we need"""
71
+ def eat_one(device):
72
+ entry = self._make_partition_namespace(0, '', '', dflt)
73
+ entry.name = device.get('name', '')
74
+ maj_min = device.get('maj:min', (-1, -1))
75
+ wds = maj_min.split(':', maxsplit=1)
76
+ entry.major = -1
77
+ if len(wds) > 0:
78
+ entry.major = int(wds[0])
79
+ entry.fstype = device.get('fstype', '')
80
+ if entry.fstype is None:
81
+ entry.fstype = ''
82
+ entry.type = device.get('type', '')
83
+ entry.label = device.get('label', '')
84
+ if not entry.label:
85
+ entry.label = device.get('partlabel', '')
86
+ if entry.label is None:
87
+ entry.label = ''
88
+ entry.size_bytes = int(device.get('size', 0))
89
+
90
+ # Get UUID - prefer PARTUUID for partitions, UUID for filesystems
91
+ entry.uuid = device.get('partuuid', '') or device.get('uuid', '') or ''
92
+ entry.serial = device.get('serial', '') or ''
93
+
94
+ mounts = device.get('mountpoints', [])
95
+ while len(mounts) >= 1 and mounts[0] is None:
96
+ del mounts[0]
97
+ entry.mounts = mounts
98
+
99
+ # Check if we should read the marker (3-state model: dont-know, got-marker, no-marker)
100
+ # Read marker ONCE when:
101
+ # 1. Not mounted
102
+ # 2. No filesystem (fstype/label empty)
103
+ # 3. No active job
104
+ # 4. Haven't checked yet (marker_checked=False)
105
+ has_job = prev_nss and entry.name in prev_nss and getattr(prev_nss[entry.name], 'job', None) is not None
106
+ has_filesystem = entry.fstype or entry.label
107
+
108
+ # Inherit marker_checked from previous scan, or False if new/changed
109
+ prev_had_filesystem = (prev_nss and entry.name in prev_nss and
110
+ (prev_nss[entry.name].fstype or prev_nss[entry.name].label))
111
+ filesystem_changed = prev_had_filesystem != bool(has_filesystem)
112
+
113
+ if prev_nss and entry.name in prev_nss and not filesystem_changed:
114
+ entry.marker_checked = prev_nss[entry.name].marker_checked
115
+
116
+ # Read marker if haven't checked yet and safe to do so
117
+ should_read_marker = (not mounts and not has_filesystem and not has_job and
118
+ not entry.marker_checked)
119
+
120
+ if should_read_marker:
121
+ entry.marker_checked = True # Mark as checked regardless of result
122
+ marker = WipeJob.read_marker_buffer(entry.name)
123
+ now = int(round(time.time()))
124
+ if (marker and marker.size_bytes == entry.size_bytes
125
+ and marker.unixtime < now):
126
+ # For multi-pass wipes, scrubbed_bytes can exceed size_bytes
127
+ # Calculate completion percentage (capped at 100%)
128
+ pct = min(100, int(round((marker.scrubbed_bytes / marker.size_bytes) * 100)))
129
+ state = 'W' if pct >= 100 else 's'
130
+ dt = datetime.datetime.fromtimestamp(marker.unixtime)
131
+ # Add verification status prefix
132
+ verify_prefix = ''
133
+ verify_status = getattr(marker, 'verify_status', None)
134
+ if verify_status == 'pass':
135
+ verify_prefix = '✓ '
136
+ elif verify_status == 'fail':
137
+ verify_prefix = '✗ '
138
+ entry.marker = f'{verify_prefix}{state} {pct}% {marker.mode} {dt.strftime("%Y/%m/%d %H:%M")}'
139
+ entry.state = state
140
+ entry.dflt = state # Set dflt so merge logic knows this partition has a marker
141
+
142
+ return entry
143
+
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 {}
154
+ entries = {}
155
+
156
+ # Parse each block device and its properties
157
+ for device in parsed_data['blockdevices']:
158
+ parent = eat_one(device)
159
+ parent.fstype = self.get_device_vendor_model(parent.name)
160
+ entries[parent.name] = parent
161
+ for child in device.get('children', []):
162
+ entry = eat_one(child)
163
+ entries[entry.name] = entry
164
+ entry.parent = parent.name
165
+ parent.minors.append(entry.name)
166
+ if not parent.fstype:
167
+ parent.fstype = 'DISK'
168
+ self.disk_majors.add(entry.major)
169
+ if entry.mounts:
170
+ entry.state = 'Mnt'
171
+ parent.state = 'Mnt'
172
+
173
+ if self.DB:
174
+ print('\n\nDB: --->>> after parse_lsblk:')
175
+ for entry in entries.values():
176
+ print(vars(entry))
177
+
178
+ return entries
179
+
180
+ @staticmethod
181
+ def set_one_state(nss, ns, to=None, test_to=None):
182
+ """Optionally, update a state, and always set inferred states"""
183
+ ready_states = ('s', 'W', '-', '^')
184
+ job_states = ('*%', 'STOP')
185
+ inferred_states = ('Busy', 'Mnt',)
186
+
187
+ def state_in(to, states):
188
+ return to in states or fnmatch(to, states[0])
189
+
190
+ to = test_to if test_to else to
191
+
192
+ parent, minors = None, []
193
+ if ns.parent:
194
+ parent = nss.get(ns.parent)
195
+ for minor in ns.minors:
196
+ minor_ns = nss.get(minor, None)
197
+ if minor_ns:
198
+ minors.append(minor_ns)
199
+
200
+ if to == 'STOP' and not state_in(ns.state, job_states):
201
+ return False
202
+ if to == 'Lock' and not state_in(ns.state, list(ready_states) + ['Mnt']):
203
+ return False
204
+ if to == 'Unlk' and ns.state != 'Lock':
205
+ return False
206
+
207
+ if to and fnmatch(to, '*%'):
208
+ if not state_in(ns.state, ready_states):
209
+ return False
210
+ for minor in minors:
211
+ if not state_in(minor.state, ready_states):
212
+ return False
213
+ elif to in ('s', 'W') and not state_in(ns.state, job_states):
214
+ return False
215
+ if test_to:
216
+ return True
217
+
218
+ if to is not None:
219
+ ns.state = to
220
+
221
+ # Here we set inferences that block starting jobs
222
+ # -- clearing these states will be done on the device refresh
223
+ if parent and state_in(ns.state, inferred_states):
224
+ if parent.state != 'Lock':
225
+ parent.state = ns.state
226
+ if state_in(ns.state, job_states):
227
+ if parent:
228
+ parent.state = 'Busy'
229
+ for minor in minors:
230
+ minor.state = 'Busy'
231
+ return True
232
+
233
+ @staticmethod
234
+ def set_all_states(nss):
235
+ """Set every state per linkage inferences"""
236
+ for ns in nss.values():
237
+ DeviceInfo.set_one_state(nss, ns)
238
+
239
+ 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')
246
+
247
+ ok_nss = {}
248
+ for name, ns in nss.items():
249
+ # Must be disk or partition type
250
+ if ns.type not in ('disk', 'part'):
251
+ continue
252
+
253
+ # Exclude virtual/pseudo devices by name prefix
254
+ if any(name.startswith(prefix) for prefix in exclude_prefixes):
255
+ continue
256
+
257
+ # Include this device
258
+ ok_nss[name] = ns
259
+
260
+ return ok_nss
261
+
262
+ def compute_field_widths(self, nss):
263
+ """Compute field widths for display formatting"""
264
+ wids = self.wids = SimpleNamespace(state=5, name=4, human=7, fstype=4, label=5)
265
+ for ns in nss.values():
266
+ wids.state = max(wids.state, len(ns.state))
267
+ wids.name = max(wids.name, len(ns.name) + 2)
268
+ if ns.label is None:
269
+ pass
270
+ wids.label = max(wids.label, len(ns.label))
271
+ wids.fstype = max(wids.fstype, len(ns.fstype))
272
+ 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
+
277
+ def get_head_str(self):
278
+ """Generate header string for device list"""
279
+ sep = ' '
280
+ wids = self.wids
281
+ emit = f'{"STATE":_^{wids.state}}'
282
+ emit += f'{sep}{"NAME":_^{wids.name}}'
283
+ emit += f'{sep}{"SIZE":_^{wids.human}}'
284
+ emit += f'{sep}{"TYPE":_^{wids.fstype}}'
285
+ emit += f'{sep}{"LABEL":_^{wids.label}}'
286
+ emit += f'{sep}MOUNTS/STATUS'
287
+ return emit
288
+
289
+ def get_pick_range(self):
290
+ """Calculate column range for pick highlighting (NAME through LABEL fields)"""
291
+ sep = ' '
292
+ wids = self.wids
293
+ # Start just before NAME field
294
+ start_col = wids.state + len(sep)
295
+ # End after LABEL field (always spans through LABEL for disks)
296
+ end_col = wids.state + len(sep) + wids.name + len(sep) + wids.human + len(sep) + wids.fstype # + len(sep) + wids.label
297
+ return [start_col, end_col]
298
+
299
+ def part_str(self, partition, is_last_child=False):
300
+ """Convert partition to human value.
301
+
302
+ Args:
303
+ partition: Partition namespace
304
+ is_last_child: If True and partition has parent, use └ instead of │
305
+
306
+ Returns:
307
+ tuple: (text, attr) where attr is curses attribute or None
308
+ """
309
+ def print_str_or_dash(name, width, empty='-'):
310
+ if not name.strip():
311
+ name = empty
312
+ return f'{name:^{width}}'
313
+
314
+ sep = ' '
315
+ ns = partition # shorthand
316
+ wids = self.wids
317
+ emit = f'{ns.state:^{wids.state}}'
318
+
319
+ # Determine tree prefix character
320
+ if ns.parent is None:
321
+ # Physical disk: box symbol
322
+ prefix = '■ '
323
+ elif is_last_child:
324
+ # Last partition of disk: rounded corner
325
+ prefix = '└ '
326
+ else:
327
+ # Regular partition: vertical line
328
+ prefix = '│ '
329
+
330
+ name_str = prefix + ns.name
331
+
332
+ emit += f'{sep}{name_str:<{wids.name}}'
333
+ emit += f'{sep}{Utils.human(ns.size_bytes):>{wids.human}}'
334
+ emit += sep + print_str_or_dash(ns.fstype, wids.fstype)
335
+ if ns.parent is None:
336
+ # Physical disk - always show thick line in LABEL field (disks don't have labels)
337
+ emit += sep + '━' * wids.label
338
+ if ns.mounts:
339
+ # Disk has mounts - show them
340
+ emit += f'{sep}{",".join(ns.mounts)}'
341
+ elif ns.marker and ns.marker.strip():
342
+ # Disk has wipe status - show it
343
+ emit += f'{sep}{ns.marker}'
344
+ else:
345
+ # No status - show heavy line divider (start 1 char left to fill gap)
346
+ emit += '━' + '━' * 30
347
+ else:
348
+ # Partition: show label and mount/status info
349
+ emit += sep + print_str_or_dash(ns.label, wids.label)
350
+ if ns.mounts:
351
+ emit += f'{sep}{",".join(ns.mounts)}'
352
+ else:
353
+ emit += f'{sep}{ns.marker}'
354
+
355
+ # Determine color attribute based on state
356
+ attr = None
357
+ # Check for newly inserted flag first (hot-swapped devices should always show orange)
358
+ if getattr(ns, 'newly_inserted', False):
359
+ # Newly inserted device - orange/bright
360
+ if ns.state in ('Mnt', 'Lock'):
361
+ # Dim the orange for mounted/locked devices
362
+ attr = curses.color_pair(Theme.HOTSWAP) | curses.A_DIM
363
+ else:
364
+ attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
365
+ elif ns.state == 's':
366
+ # Yellow/warning color for stopped/partial wipes (with bold for visibility)
367
+ attr = curses.color_pair(Theme.WARNING) | curses.A_BOLD
368
+ elif ns.state == 'W' and getattr(ns, 'wiped_this_session', False):
369
+ # Green/success color for completed wipes (done in THIS session only) - bold and bright
370
+ attr = curses.color_pair(Theme.SUCCESS) | curses.A_BOLD
371
+ elif ns.state == 'W':
372
+ # Green/success color for completed wipes before this session
373
+ attr = curses.color_pair(Theme.OLD_SUCCESS) | curses.A_BOLD
374
+ elif ns.state.endswith('%') and ns.state not in ('0%', '100%'):
375
+ # Active wipe in progress - bright cyan/blue with bold
376
+ attr = curses.color_pair(Theme.INFO) | curses.A_BOLD
377
+ elif ns.state == '^':
378
+ # Newly inserted device (hot-swapped) - orange/bright
379
+ attr = curses.color_pair(Theme.HOTSWAP) | curses.A_BOLD
380
+ elif ns.state in ('Mnt', 'Lock'):
381
+ # Dim mounted or locked devices
382
+ attr = curses.A_DIM
383
+
384
+ # Override with red/danger color if verify failed
385
+ if hasattr(ns, 'verify_failed_msg') and ns.verify_failed_msg:
386
+ attr = curses.color_pair(Theme.DANGER) | curses.A_BOLD
387
+
388
+ return emit, attr
389
+
390
+ def merge_dev_infos(self, nss, prev_nss=None):
391
+ """Merge old DevInfos into new DevInfos"""
392
+ if not prev_nss:
393
+ return nss
394
+
395
+ # Track which devices were physically present in last scan
396
+ prev_physical = set()
397
+ for name, prev_ns in prev_nss.items():
398
+ # Only count as "physically present" if not carried forward due to job
399
+ if not (hasattr(prev_ns, 'was_unplugged') and prev_ns.was_unplugged):
400
+ prev_physical.add(name)
401
+
402
+ for name, prev_ns in prev_nss.items():
403
+ # merge old jobs forward
404
+ new_ns = nss.get(name, None)
405
+ if new_ns:
406
+ if prev_ns.job:
407
+ new_ns.job = prev_ns.job
408
+ new_ns.dflt = prev_ns.dflt
409
+ # Preserve the "wiped this session" flag
410
+ if hasattr(prev_ns, 'wiped_this_session'):
411
+ new_ns.wiped_this_session = prev_ns.wiped_this_session
412
+ # Preserve marker and marker_checked (already inherited in parse_lsblk)
413
+ # Only preserve marker string if we haven't just read a new one
414
+ if hasattr(prev_ns, 'marker') and not new_ns.marker:
415
+ new_ns.marker = prev_ns.marker
416
+
417
+ # Preserve verify failure message ONLY for unmarked disks
418
+ # Clear if: filesystem appeared OR partition now has a marker
419
+ if hasattr(prev_ns, 'verify_failed_msg'):
420
+ # Check if partition now has marker (dflt is 'W' or 's', not '-')
421
+ has_marker = new_ns.dflt in ('W', 's')
422
+
423
+ # For whole disks (no parent): check if any child partition has filesystem
424
+ # For partitions: check if this partition has filesystem
425
+ has_filesystem = False
426
+ if not new_ns.parent:
427
+ # Whole disk - check if any child has fstype or label
428
+ for _, child_ns in nss.items():
429
+ if child_ns.parent == name and (child_ns.fstype or child_ns.label):
430
+ has_filesystem = True
431
+ break
432
+ else:
433
+ # Partition - check if it has fstype or label
434
+ has_filesystem = bool(new_ns.fstype or new_ns.label)
435
+
436
+ if has_filesystem or has_marker:
437
+ # Filesystem appeared or now has marker - clear the error
438
+ # (verify_failed_msg is only for unmarked disks)
439
+ if hasattr(new_ns, 'verify_failed_msg'):
440
+ delattr(new_ns, 'verify_failed_msg')
441
+ else:
442
+ # Still unmarked with no filesystem - persist the error
443
+ new_ns.verify_failed_msg = prev_ns.verify_failed_msg
444
+ new_ns.mounts = [prev_ns.verify_failed_msg]
445
+
446
+ if prev_ns.state == 'Lock':
447
+ new_ns.state = 'Lock'
448
+ elif new_ns.state not in ('s', 'W'):
449
+ new_ns.state = new_ns.dflt
450
+ # 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('%'):
452
+ new_ns.state = prev_ns.state # re-infer these
453
+ elif prev_ns.job:
454
+ # unplugged device with job..
455
+ prev_ns.was_unplugged = True # Mark as unplugged
456
+ nss[name] = prev_ns # carry forward
457
+ prev_ns.job.do_abort = True
458
+
459
+ # Mark newly inserted devices (not present in previous physical scan)
460
+ for name, new_ns in nss.items():
461
+ if name not in prev_physical and new_ns.state not in ('s', 'W'):
462
+ new_ns.state = '^'
463
+ new_ns.newly_inserted = True # Mark for orange color even if locked/mounted
464
+ return nss
465
+
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)
469
+
470
+ nss = self.get_disk_partitions(nss)
471
+
472
+ nss = self.merge_dev_infos(nss, prev_nss)
473
+
474
+ # Apply persistent locked states
475
+ if self.persistent_state:
476
+ for ns in nss.values():
477
+ # Update last_seen timestamp
478
+ self.persistent_state.update_device_seen(ns)
479
+ # Apply persistent lock state
480
+ if self.persistent_state.get_device_locked(ns):
481
+ ns.state = 'Lock'
482
+
483
+ self.set_all_states(nss) # set inferred states
484
+
485
+ 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
+ return nss