dwipe 1.0.7__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 +492 -0
- dwipe/DiskWipe.py +880 -0
- dwipe/PersistentState.py +195 -0
- dwipe/Utils.py +199 -0
- dwipe/WipeJob.py +1243 -0
- dwipe/WipeJobFuture.py +245 -0
- dwipe/main.py +19 -853
- dwipe-2.0.0.dist-info/METADATA +407 -0
- dwipe-2.0.0.dist-info/RECORD +13 -0
- dwipe-1.0.7.dist-info/METADATA +0 -72
- dwipe-1.0.7.dist-info/RECORD +0 -7
- {dwipe-1.0.7.dist-info → dwipe-2.0.0.dist-info}/WHEEL +0 -0
- {dwipe-1.0.7.dist-info → dwipe-2.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-1.0.7.dist-info → dwipe-2.0.0.dist-info}/licenses/LICENSE +0 -0
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
|