dwipe 1.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/main.py ADDED
@@ -0,0 +1,806 @@
1
+ #!/usr/bin/env python3
2
+ """ TBD
3
+
4
+
5
+ """
6
+ # pylint: disable=too-many-branches,too-many-statements,import-outside-toplevel
7
+ # pylint: disable=too-many-instance-attributes,invalid-name
8
+ # pylint: disable=broad-exception-caught,consider-using-with
9
+ # pylint: disable=too-many-return-statements,too-many-locals
10
+
11
+ import os
12
+ from fnmatch import fnmatch
13
+ import sys
14
+ import re
15
+ import json
16
+ import subprocess
17
+ import time
18
+ import threading
19
+ import random
20
+ import shutil
21
+ import traceback
22
+ import curses as cs
23
+ from types import SimpleNamespace
24
+ from typing import Tuple, List
25
+ from dwipe.PowerWindow import Window, OptionSpinner
26
+
27
+ def human(number):
28
+ """ Return a concise number description."""
29
+ suffixes = ['K', 'M', 'G', 'T']
30
+ number = float(number)
31
+ while suffixes:
32
+ suffix = suffixes.pop(0)
33
+ number /= 1024
34
+ if number < 99.95 or not suffixes:
35
+ return f'{number:.1f}{suffix}'
36
+ return None
37
+ ##############################################################################
38
+ def ago_str(delta_secs, signed=False):
39
+ """ Turn time differences in seconds to a compact representation;
40
+ e.g., '18h·39m'
41
+ """
42
+ ago = int(max(0, round(delta_secs if delta_secs >= 0 else -delta_secs)))
43
+ divs = (60, 60, 24, 7, 52, 9999999)
44
+ units = ('s', 'm', 'h', 'd', 'w', 'y')
45
+ vals = (ago%60, int(ago/60)) # seed with secs, mins (step til 2nd fits)
46
+ uidx = 1 # best units
47
+ for div in divs[1:]:
48
+ # print('vals', vals, 'div', div)
49
+ if vals[1] < div:
50
+ break
51
+ vals = (vals[1]%div, int(vals[1]/div))
52
+ uidx += 1
53
+ rv = '-' if signed and delta_secs < 0 else ''
54
+ rv += f'{vals[1]}{units[uidx]}' if vals[1] else ''
55
+ rv += f'{vals[0]:d}{units[uidx-1]}'
56
+ return rv
57
+
58
+
59
+ class WipeJob:
60
+ """ TBD """
61
+
62
+ # Generate a 1MB buffer of random data
63
+ BUFFER_SIZE = 1 * 1024 * 1024 # 1MB
64
+ WRITE_SIZE = 16 * 1024 # 16KB
65
+ buffer = bytearray(os.urandom(BUFFER_SIZE))
66
+ zero_buffer = bytes(WRITE_SIZE)
67
+
68
+ # Shared status string
69
+
70
+ def __init__(self, device_path, total_size, opts=None):
71
+ self.opts = opts if opts else SimpleNamespace(dry_run=False)
72
+ self.device_path = device_path
73
+ self.total_size = total_size
74
+ self.do_abort = False
75
+ self.thread = None
76
+
77
+ self.start_mono = time.monotonic() # Track the start time
78
+ self.total_written = 0
79
+ self.wr_hists = [] # list of (mono, written)
80
+ self.done = False
81
+
82
+ @staticmethod
83
+ def start_job(device_path, total_size, opts):
84
+ """ TBD """
85
+ job = WipeJob(device_path=device_path, total_size=total_size, opts=opts)
86
+ job.thread = threading.Thread(target=job.write_partition)
87
+ job.wr_hists.append(SimpleNamespace(mono=time.monotonic(), written=0))
88
+ job.thread.start()
89
+ return job
90
+
91
+ def get_status_str(self):
92
+ """ TBD """
93
+ elapsed_time = time.monotonic() - self.start_mono
94
+ write_rate = self.total_written / elapsed_time if elapsed_time > 0 else 0
95
+ percent_complete = (self.total_written / self.total_size) * 100
96
+ return (f"Write rate: {write_rate / (1024 * 1024):.2f} MB/s, "
97
+ f"Completed: {percent_complete:.2f}%")
98
+
99
+ def get_status(self):
100
+ """ TBD """
101
+ pct_str, rate_str, when_str = '', '', ''
102
+ mono = time.monotonic()
103
+ written = self.total_written
104
+ elapsed_time = mono - self.start_mono
105
+
106
+ pct = (self.total_written / self.total_size) * 100
107
+ pct_str = f'{int(round(pct))}%'
108
+ if self.do_abort:
109
+ pct_str = 'STOP'
110
+
111
+ self.wr_hists.append(SimpleNamespace(mono=mono, written=written))
112
+ floor = mono - 30 # 30w moving average
113
+ while len(self.wr_hists) >= 3 and self.wr_hists[1].mono >= floor:
114
+ del self.wr_hists[0]
115
+ delta_mono = mono - self.wr_hists[0].mono
116
+ rate = (written - self.wr_hists[0].written) / delta_mono if delta_mono > 1.0 else 0
117
+ rate_str = f'{human(int(round(rate, 0)))}/s'
118
+
119
+ if rate > 0:
120
+ when = int(round((self.total_size - self.total_written)/rate))
121
+ when_str = ago_str(when)
122
+
123
+
124
+ return ago_str(int(round(elapsed_time))), pct_str, rate_str, when_str
125
+
126
+ def write_partition(self):
127
+ """Writes random chunks to a device and updates the progress status."""
128
+ self.total_written = 0 # Track total bytes written
129
+ is_random = self.opts.random
130
+
131
+ with open(self.device_path, 'wb') as device:
132
+ # for loop in range(10000000000):
133
+ offset = 0
134
+ chunk = memoryview(WipeJob.zero_buffer)
135
+ while True:
136
+ if self.do_abort:
137
+ break
138
+ if is_random:
139
+ offset = random.randint(0, WipeJob.BUFFER_SIZE - WipeJob.WRITE_SIZE)
140
+ # Use memoryview to avoid copying the data
141
+ chunk = memoryview(WipeJob.buffer)[offset:offset + WipeJob.WRITE_SIZE]
142
+
143
+ if self.opts.dry_run:
144
+ bytes_written = self.total_size // 120
145
+ time.sleep(0.25)
146
+ else:
147
+ try:
148
+ bytes_written = device.write(chunk)
149
+ except Exception:
150
+ bytes_written = 0
151
+ self.total_written += bytes_written
152
+ # Optional: Check for errors or incomplete writes
153
+ if bytes_written < WipeJob.WRITE_SIZE:
154
+ break
155
+ if self.opts.dry_run and self.total_written >= self.total_size:
156
+ break
157
+ # clear the beginning of device whether aborted or not
158
+ # if we have started writing
159
+ if not self.opts.dry_run and self.total_written > 0 and is_random:
160
+ device.seek(0)
161
+ chunk = memoryview(WipeJob.zero_buffer)
162
+ bytes_written = device.write(chunk)
163
+ self.done = True
164
+
165
+ class DeviceInfo:
166
+ """ Class to dig out the info we want from the system."""
167
+ disk_majors = set() # major devices that are disks
168
+
169
+ def __init__(self, opts):
170
+ self.opts = opts
171
+ self.DB = opts.debug
172
+ self.wids = None
173
+ self.head_str = None
174
+ self.partitions = None
175
+
176
+ @staticmethod
177
+ def _make_partition_namespace(major, name, size_bytes, dflt):
178
+ return SimpleNamespace(name=name, # /proc/partitions
179
+ major=major, # /proc/partitions
180
+ # minor=minor, # /proc/partitions
181
+ parent=None, # a partition
182
+ state=dflt, # run-time state
183
+ dflt=dflt, # default run-time state
184
+ label='', # blkid
185
+ fstype='', # blkid
186
+ # fsuse='-',
187
+ size_bytes=size_bytes, # /sys/block/{name}/...
188
+ mounts=[], # /proc/mounts
189
+ minors=[],
190
+ job=None, # if zap running
191
+ )
192
+
193
+ def parse_lsblk(self, dflt):
194
+ """ Parse ls_blk for all the goodies we need """
195
+ def eat_one(device):
196
+ entry = self._make_partition_namespace(0, '', '', dflt)
197
+ entry.name=device.get('name', '')
198
+ maj_min=device.get('maj:min', (-1,-1))
199
+ wds = maj_min.split(':', maxsplit=1)
200
+ entry.major = -1
201
+ if len(wds) > 0:
202
+ entry.major = int(wds[0])
203
+ entry.fstype = device.get('fstype', '')
204
+ if entry.fstype is None:
205
+ entry.fstype = ''
206
+ entry.label = device.get('label', '')
207
+ if not entry.label:
208
+ entry.label=device.get('partlabel', '')
209
+ if entry.label is None:
210
+ entry.label = ''
211
+ # entry.fsuse=device.get('fsuse%', '')
212
+ # if entry.fsuse is None:
213
+ # entry.fsuse = '-'
214
+ entry.size_bytes=int(device.get('size', 0))
215
+ mounts = device.get('mountpoints', [])
216
+ while len(mounts) >= 1 and mounts[0] is None:
217
+ del mounts[0]
218
+ entry.mounts = mounts
219
+ return entry
220
+
221
+ # Run the `lsblk` command and get its output in JSON format with additional columns
222
+ result = subprocess.run(['lsblk', '-J', '--bytes', '-o',
223
+ 'NAME,MAJ:MIN,FSTYPE,LABEL,PARTLABEL,FSUSE%,SIZE,MOUNTPOINTS', ],
224
+ stdout=subprocess.PIPE, text=True, check=False)
225
+ parsed_data = json.loads(result.stdout)
226
+ entries = {}
227
+
228
+ # Parse each block device and its properties
229
+ for device in parsed_data['blockdevices']:
230
+ parent = eat_one(device)
231
+ entries[parent.name] = parent
232
+ for child in device.get('children', []):
233
+ entry = eat_one(child)
234
+ entries[entry.name] = entry
235
+ entry.parent = parent.name
236
+ parent.minors.append(entry.name)
237
+ if not parent.fstype:
238
+ parent.fstype = 'DISK'
239
+ self.disk_majors.add(entry.major)
240
+ if entry.mounts:
241
+ entry.state = 'Mnt'
242
+ parent.state = 'Mnt'
243
+
244
+ if self.DB:
245
+ print('\n\nDB: --->>> after parse_lsblk:')
246
+ for entry in entries.values():
247
+ print(vars(entry))
248
+
249
+ return entries
250
+
251
+ @staticmethod
252
+ def set_one_state(nss, ns, to=None, test_to=None):
253
+ """ Optionally, update a state, and always set inferred states
254
+ """
255
+ ready_states = ('s', 'W', '-', '^')
256
+ job_states = ('*%', 'STOP')
257
+ inferred_states = ('Busy', 'Mnt', )
258
+ # other_states = ('Lock', 'Unlk')
259
+
260
+ def state_in(to, states):
261
+ return to in states or fnmatch(to, states[0])
262
+
263
+ to = test_to if test_to else to
264
+
265
+ parent, minors = None, []
266
+ if ns.parent:
267
+ parent = nss.get(ns.parent)
268
+ for minor in ns.minors:
269
+ minor_ns = nss.get(minor, None)
270
+ if minor_ns:
271
+ minors.append(minor_ns)
272
+
273
+ if to == 'STOP' and not state_in(ns.state, job_states):
274
+ return False
275
+ if to == 'Lock' and (not state_in(ns.state, list(ready_states) + ['Mnt'])
276
+ or ns.parent):
277
+ return False
278
+ if to == 'Unlk' and ns.state != 'Lock':
279
+ return False
280
+
281
+ if to and fnmatch(to, '*%'):
282
+ if not state_in(ns.state, ready_states):
283
+ return False
284
+ for minor in minors:
285
+ if not state_in(minor.state, ready_states):
286
+ return False
287
+ elif to in ('s', 'W') and not state_in(ns.state, job_states):
288
+ return False
289
+ if test_to:
290
+ return True
291
+
292
+ if to is not None:
293
+ ns.state = to
294
+
295
+ # Here we set inferences that block starting jobs
296
+ # -- clearing these states will be done on the device
297
+ # refresh
298
+ if parent and state_in(ns.state, inferred_states):
299
+ if parent.state != 'Lock':
300
+ parent.state = ns.state
301
+ if state_in(ns.state, job_states):
302
+ if parent:
303
+ parent.state = 'Busy'
304
+ for minor in minors:
305
+ minor.state = 'Busy'
306
+ return True
307
+
308
+ @staticmethod
309
+ def set_all_states(nss):
310
+ """ Set every state per linkage inferences """
311
+ for ns in nss.values():
312
+ DeviceInfo.set_one_state(nss, ns)
313
+
314
+ def get_disk_partitions(self, nss):
315
+ """ Determine which partitions we want some are bogus like zram """
316
+
317
+ def whitelisted(device_name):
318
+ """Check if device_name matches any pattern in whitelist
319
+ which are the disk devices."""
320
+ WHITELIST = ['nvme*', 'sd*', 'hd*', 'mmcblk*']
321
+ for pattern in WHITELIST:
322
+ if fnmatch(device_name, pattern):
323
+ return True
324
+ return False
325
+
326
+ def blacklisted(device_name):
327
+ """Check if device_name matches any pattern in black list
328
+ which are know not to be physical disks."""
329
+ BLACKLIST = ['zram*', 'ram*', 'dm-*', 'loop*', 'sr*']
330
+ for pattern in BLACKLIST:
331
+ if fnmatch(device_name, pattern):
332
+ return 'blkLst'
333
+ return ''
334
+
335
+ def writable(device_name):
336
+ """Check if the device is writable."""
337
+ device_path = f'/dev/{device_name}'
338
+ try: # Check if the device file exists and is writable
339
+ return os.access(device_path, os.W_OK)
340
+ except FileNotFoundError:
341
+ return False
342
+
343
+ ok_nss = {}
344
+ for name, ns in nss.items():
345
+ if ns.major in self.disk_majors or whitelisted(name):
346
+ ok_nss[name] = ns
347
+ continue
348
+ if blacklisted(name):
349
+ continue
350
+ if writable(name):
351
+ if self.DB:
352
+ print(r'DB:include {repr(name)} [not white/black but writable]')
353
+ ok_nss[name] = ns
354
+ continue
355
+ if self.DB:
356
+ print(r'DB:exclude {repr(name)} [not white/black but unwritable]')
357
+ return ok_nss
358
+
359
+ def compute_field_widths(self, nss):
360
+ """ TBD """
361
+
362
+ wids = self.wids = SimpleNamespace(state=5, name=4, human=6, fstype=4, label=5)
363
+ for ns in nss.values():
364
+ wids.state = max(wids.state, len(ns.state))
365
+ # wids.fsuse = max(wids.fsuse, len(ns.fsuse))
366
+ wids.name = max(wids.name, len(ns.name)+2)
367
+ if ns.label is None:
368
+ pass
369
+ wids.label = max(wids.label, len(ns.label))
370
+ wids.fstype = max(wids.fstype, len(ns.fstype))
371
+ self.head_str = self.get_head_str()
372
+ if self.DB:
373
+ print('\n\nDB: --->>> after compute_field_widths():')
374
+ print(f'self.wids={vars(wids)}')
375
+
376
+ def get_head_str(self):
377
+ """ TBD """
378
+ sep = ' '
379
+ wids = self.wids
380
+ emit = f'{"STATE":_^{wids.state}}'
381
+ emit += f'{sep}{"NAME":_^{wids.name}}'
382
+ # emit += f'{sep}{"USE%":_^{wids.fsuse}}'
383
+ emit += f'{sep}{"SIZE":_^{wids.human}}'
384
+ emit += f'{sep}{"TYPE":_^{wids.fstype}}'
385
+ emit += f'{sep}{"LABEL":_^{wids.label}}'
386
+ emit += f'{sep}MOUNTS'
387
+ return emit
388
+
389
+ def part_str(self, partition):
390
+ """ Convert partition to human value. """
391
+ def print_str_or_dashes(name, width, chrs=' -'):
392
+ if not name.strip(): # Create a string of '─' characters of the specified width
393
+ result = f'{chrs}' * (width//2)
394
+ result += ' ' * (width%2)
395
+ else: # Format the name to be right-aligned within the specified width
396
+ result = f'{name:>{width}}'
397
+ return result
398
+
399
+ sep = ' '
400
+ ns = partition # shorthand
401
+ wids = self.wids
402
+ emit = f'{ns.state:^{wids.state}}'
403
+ # name_str = ('' if ns.parent is None else '⮞ ') + ns.name
404
+ #name_str = ('● ' if ns.parent is None else ' ') + ns.name
405
+ name_str = ('■ ' if ns.parent is None else ' ') + ns.name
406
+ # if ns.parent is None and 1 + len(name_str) <= wids.name:
407
+ # name_str += ' ' * (wids.name - len(name_str) - 1) + '■'
408
+
409
+ emit += f'{sep}{name_str:<{wids.name}}'
410
+ # emit += f'{sep}{ns.fsuse:^{wids.fsuse}}'
411
+ emit += f'{sep}{human(ns.size_bytes):>{wids.human}}'
412
+ emit += sep + print_str_or_dashes(ns.fstype, wids.fstype)
413
+ # emit += f'{sep}{ns.fstype:>{wids.fstype}}'
414
+ if ns.parent is None:
415
+ emit += sep + '■' + '─'*(wids.label-2) + '■'
416
+ else:
417
+ emit += sep + print_str_or_dashes(ns.label, wids.label, chrs=' -')
418
+ # emit += f' {ns.label:>{wids.label}}'
419
+ emit += f'{sep}{",".join(ns.mounts)}'
420
+ return emit
421
+
422
+ def merge_dev_infos(self, nss, prev_nss=None):
423
+ """ Merge old DevInfos into new DevInfos """
424
+ if not prev_nss:
425
+ return nss
426
+ for name, prev_ns in prev_nss.items():
427
+ # merge old jobs forward
428
+ new_ns = nss.get(name, None)
429
+ if new_ns:
430
+ if prev_ns.job:
431
+ new_ns.job = prev_ns.job
432
+ new_ns.state = new_ns.dflt = prev_ns.dflt
433
+ if prev_ns.state == 'Lock':
434
+ pass
435
+ if prev_ns.state not in ('Busy', 'Unlk'): # re-infer these
436
+ new_ns.state = prev_ns.state
437
+ elif prev_ns.job:
438
+ # unplugged device with job..
439
+ nss[name] = prev_ns # carry forward
440
+ prev_ns.job.do_abort = True
441
+ for name, prev_ns in nss.items():
442
+ if name not in prev_nss:
443
+ prev_ns.state = '^'
444
+ return nss
445
+
446
+ def assemble_partitions(self, prev_nss=None):
447
+ """ TBD """
448
+ nss = self.parse_lsblk(dflt='^' if prev_nss else '-')
449
+
450
+ nss = self.get_disk_partitions(nss)
451
+
452
+ nss = self.merge_dev_infos(nss, prev_nss)
453
+
454
+ self.set_all_states(nss) # set inferred states
455
+
456
+ self.compute_field_widths(nss)
457
+
458
+ if self.DB:
459
+ print('\n\nDB: --->>> after assemble_partitions():')
460
+ for name, ns in nss.items():
461
+ print(f'DB: {name}: {vars(ns)}')
462
+ self.partitions = nss
463
+ return nss
464
+
465
+ class DiskWipe:
466
+ """" TBD """
467
+ singleton = None
468
+ def __init__(self, opts=None):
469
+ DiskWipe.singleton = self
470
+ self.opts = opts if opts else SimpleNamespace(debug=0, dry_run=False)
471
+ self.DB = bool(self.opts.debug)
472
+ self.mounts_lines = None
473
+ self.partitions = {} # a dict of namespaces keyed by name
474
+ self.visibles = [] # visible partitions given the filter
475
+ self.wids = None
476
+ self.job_cnt = 0
477
+ self.exit_when_no_jobs = False
478
+
479
+ self.prev_filter = '' # string
480
+ self.filter = None # compiled pattern
481
+ self.pick_is_running = False
482
+ self.pick_name = '' # device name of current pick line
483
+ self.pick_actions = {} # key, tag
484
+ self.dev_info = None
485
+
486
+ # EXPAND
487
+ self.win, self.spin = None, None
488
+
489
+ self.check_preqreqs()
490
+
491
+ @staticmethod
492
+ def check_preqreqs():
493
+ """ Check that needed programs are installed. """
494
+ ok = True
495
+ for prog in 'lsblk'.split():
496
+ if shutil.which(prog) is None:
497
+ ok = False
498
+ print(f'ERROR: cannot find {prog!r} on $PATH')
499
+ if not ok:
500
+ sys.exit(1)
501
+
502
+ def test_state(self, ns, to=None):
503
+ """ Test if OK to set state of partition """
504
+ return self.dev_info.set_one_state(self.partitions, ns, test_to=to)
505
+
506
+ def set_state(self, ns, to=None):
507
+ """ Set state of partition """
508
+ return self.dev_info.set_one_state(self.partitions, ns, to=to)
509
+
510
+ @staticmethod
511
+ def mod_pick(line):
512
+ """ Callback to modify the "pick line" being highlighted;
513
+ We use it to alter the state
514
+ """
515
+ this = DiskWipe.singleton
516
+ this.pick_name, this.pick_actions = this.get_actions(line)
517
+ header = this.get_keys_line()
518
+ # ASSUME line ends in /....
519
+ parts = header.split('/', maxsplit=1)
520
+ wds = parts[0].split()
521
+ this.win.head.pad.move(0, 0)
522
+ for wd in wds:
523
+ if wd[0]in ('<', '|', '❚'):
524
+ this.win.add_header(wd + ' ', resume=True)
525
+ continue
526
+ if wd:
527
+ this.win.add_header(wd[0], attr=cs.A_BOLD|cs.A_UNDERLINE, resume=True)
528
+ if wd[1:]:
529
+ this.win.add_header(wd[1:] + ' ', resume=True)
530
+
531
+ this.win.add_header('/', attr=cs.A_BOLD+cs.A_UNDERLINE, resume=True)
532
+ if len(parts) > 1 and parts[1]:
533
+ this.win.add_header(f'{parts[1]}', resume=True)
534
+ _, col = this.win.head.pad.getyx()
535
+ pad = ' ' * (this.win.get_pad_width()-col)
536
+ this.win.add_header(pad, resume=True)
537
+ return line
538
+ def do_key(self, key):
539
+ """ TBD """
540
+ def stop_if_idle(part):
541
+ if part.state[-1] == '%':
542
+ if part.job and not part.job.done:
543
+ part.job.do_abort = True
544
+ return 1 if part.job else 0
545
+
546
+ def stop_all():
547
+ rv = 0
548
+ for part in self.partitions.values():
549
+ rv += stop_if_idle(part)
550
+ return rv # number jobs running
551
+
552
+ def exit_if_no_jobs():
553
+ if stop_all() == 0:
554
+ self.win.stop_curses()
555
+ os.system('clear; stty sane')
556
+ sys.exit(0)
557
+ return True # continue running
558
+
559
+ if self.exit_when_no_jobs:
560
+ return exit_if_no_jobs()
561
+
562
+ if not key:
563
+ return True
564
+ if key in (cs.KEY_ENTER, 10): # Handle ENTER
565
+ if self.opts.help_mode:
566
+ self.opts.help_mode = False
567
+ return None
568
+
569
+ if key in self.spin.keys:
570
+ value = self.spin.do_key(key, self.win)
571
+ return value
572
+
573
+ if key == 27: # ESCAPE
574
+ self.prev_filter = ''
575
+ self.filter = None
576
+ self.win.pick_pos = 0
577
+ return None
578
+
579
+ if key in (ord('q'), ord('x')):
580
+ self.exit_when_no_jobs = True
581
+ self.filter = re.compile('STOPPING', re.IGNORECASE)
582
+ self.prev_filter = 'STOPPING'
583
+ return exit_if_no_jobs()
584
+
585
+ if key == ord('w') and not self.pick_is_running:
586
+ part = self.partitions[self.pick_name]
587
+ if self.test_state(part, to='0%'):
588
+ ans = self.win.answer(f'Type "y" to wipe {repr(part.name)} : '
589
+ + f' st={repr(part.state)} sz={human(part.size_bytes)}'
590
+ + f' ty={part.fstype} label={part.label}'
591
+ )
592
+ if ans.strip().lower().startswith('y'):
593
+ part.job = WipeJob.start_job(f'/dev/{part.name}',
594
+ part.size_bytes, opts=self.opts)
595
+ self.job_cnt += 1
596
+ self.set_state(part, to='0%')
597
+ return None
598
+
599
+ if key == ord('s') and self.pick_is_running:
600
+ part = self.partitions[self.pick_name]
601
+ stop_if_idle(part)
602
+ return None
603
+
604
+ if key == ord('S'):
605
+ for part in self.partitions.values():
606
+ stop_if_idle(part)
607
+ return None
608
+
609
+ if key == ord('l'):
610
+ part = self.partitions[self.pick_name]
611
+ self.set_state(part, 'Unlk' if part.state == 'Lock' else 'Lock')
612
+
613
+ if key == ord('/'):
614
+ # pylint: disable=protected-access
615
+ start_filter = self.prev_filter
616
+
617
+ prefix = ''
618
+ while True:
619
+ pattern = self.win.answer(f'{prefix}Enter filter regex:', seed=self.prev_filter)
620
+ self.prev_filter = pattern
621
+
622
+ pattern.strip()
623
+ if not pattern:
624
+ self.filter = None
625
+ break
626
+
627
+ try:
628
+ self.filter = re.compile(pattern, re.IGNORECASE)
629
+ break
630
+ except Exception:
631
+ prefix = 'Bad regex: '
632
+
633
+ if start_filter != self.prev_filter:
634
+ # when filter changes, move to top
635
+ self.win.pick_pos = 0
636
+
637
+ return None
638
+ return None
639
+
640
+ def get_keys_line(self):
641
+ """ TBD """
642
+ # KEYS
643
+ line = ''
644
+ for key, verb in self.pick_actions.items():
645
+ if key[0] == verb[0]:
646
+ line += f' {verb}'
647
+ else:
648
+ line += f' {key}:{verb}'
649
+ line += ' ❚'
650
+ line += ' Stop' if self.job_cnt > 0 else ''
651
+ line += f' quit ?:help /{self.prev_filter} Mode='
652
+ line += f'{"Random" if self.opts.random else "Zeros"}'
653
+ # for action in self.actions:
654
+ # line += f' {action[0]}:{action}'
655
+ return line[1:]
656
+
657
+ def get_actions(self, part):
658
+ """ Determine the type of the current line and available commands."""
659
+ # KEYS
660
+ name, actions = '', {}
661
+ lines = self.win.body.texts
662
+ if 0 <= self.win.pick_pos < len(lines):
663
+ # line = lines[self.win.pick_pos]
664
+ part = self.visibles[self.win.pick_pos]
665
+ name = part.name
666
+ self.pick_is_running = bool(part.job)
667
+ # EXPAND
668
+ if self.test_state(part, to='STOP'):
669
+ actions['s'] = 'stop'
670
+ elif self.test_state(part, to='0%'):
671
+ actions['w'] = 'wipe'
672
+ if self.test_state(part, to='Lock'):
673
+ actions['l'] = 'lock'
674
+ if self.test_state(part, to='Unlk'):
675
+ actions['l'] = 'unlk'
676
+ return name, actions
677
+
678
+ def main_loop(self):
679
+ """ TBD """
680
+
681
+ spin = self.spin = OptionSpinner()
682
+ spin.default_obj = self.opts
683
+ spin.add_key('help_mode', '? - toggle help screen', vals=[False, True])
684
+ spin.add_key('random', 'r - toggle whether random or zeros', vals=[True, False])
685
+ # KEYS
686
+ other = 'wsl/Sqx'
687
+ other_keys = set(ord(x) for x in other)
688
+ other_keys.add(cs.KEY_ENTER)
689
+ other_keys.add(10) # another form of ENTER
690
+ other_keys.add(27) # ESCAPE
691
+
692
+ self.win = Window(head_line=True, body_rows=200, head_rows=4,
693
+ keys=spin.keys ^ other_keys, mod_pick=self.mod_pick)
694
+ self.opts.name = "[hit 'n' to enter name]"
695
+ check_devices_mono = time.monotonic()
696
+ while True:
697
+ if self.opts.help_mode:
698
+ self.win.set_pick_mode(False)
699
+ self.spin.show_help_nav_keys(self.win)
700
+ self.spin.show_help_body(self.win)
701
+ # KEYS
702
+ lines = [
703
+ 'CONTEXT SENSITIVE:',
704
+ ' w - wipe device',
705
+ ' s - stop wipe device',
706
+ ' l - lock/unlock disk',
707
+ ' S - stop ALL wipes in progress',
708
+ 'GENERALLY AVAILABLE:',
709
+ ' q or x - quit program (CTL-C disabled)',
710
+ ' / - filter devices by (anchored) regex',
711
+ ' ESC = clear filter and jump to top',
712
+ ' ENTER = return from help',
713
+
714
+ ]
715
+ for line in lines:
716
+ self.win.put_body(line)
717
+ else:
718
+ def wanted(name):
719
+ return not self.filter or self.filter.search(name)
720
+ # self.win.set_pick_mode(self.opts.pick_mode, self.opts.pick_size)
721
+ self.win.set_pick_mode(True)
722
+
723
+ self.visibles = []
724
+ for name, partition in self.partitions.items():
725
+ partition.line = None
726
+ if partition.job:
727
+ if partition.job.done:
728
+ partition.job.thread.join()
729
+ to='s' if partition.job.do_abort else 'W'
730
+ self.set_state(partition, to=to)
731
+ partition.dflt = to
732
+ partition.job = None
733
+ partition.mounts = []
734
+ self.job_cnt -= 1
735
+ if partition.job:
736
+ elapsed, pct, rate, until = partition.job.get_status()
737
+ partition.state = pct
738
+ partition.mounts = [f'{elapsed} {rate} REM:{until}']
739
+
740
+ if partition.parent and self.partitions[partition.parent].state == 'Lock':
741
+ continue
742
+
743
+ if wanted(name) or partition.job:
744
+ partition.line = self.dev_info.part_str(partition)
745
+ self.win.add_body(partition.line)
746
+ self.visibles.append(partition)
747
+
748
+ self.win.add_header(self.get_keys_line(), attr=cs.A_BOLD)
749
+
750
+ self.win.add_header(self.dev_info.head_str)
751
+ _, col = self.win.head.pad.getyx()
752
+ pad = ' ' * (self.win.get_pad_width()-col)
753
+ self.win.add_header(pad, resume=True)
754
+ self.win.render()
755
+
756
+ seconds = 3.0
757
+ _ = self.do_key(self.win.prompt(seconds=seconds))
758
+
759
+ if time.monotonic() - check_devices_mono > (seconds * 0.95):
760
+ info = DeviceInfo(opts=self.opts)
761
+ self.partitions = info.assemble_partitions(self.partitions)
762
+ self.dev_info = info
763
+ check_devices_mono = time.monotonic()
764
+
765
+ self.win.clear()
766
+
767
+
768
+ def rerun_module_as_root(module_name):
769
+ """ rerun using the module name """
770
+ if os.geteuid() != 0: # Re-run the script with sudo
771
+ os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
772
+ vp = ['sudo', sys.executable, '-m', module_name] + sys.argv[1:]
773
+ os.execvp('sudo', vp)
774
+
775
+
776
+ def main():
777
+ """Main loop"""
778
+ import argparse
779
+ parser = argparse.ArgumentParser()
780
+ parser.add_argument('-n', '--dry-run', action='store_true',
781
+ help='just pretend to zap devices')
782
+ parser.add_argument('-D', '--debug', action='count', default=0,
783
+ help='debug mode (the more Ds, the higher the debug level)')
784
+ opts = parser.parse_args()
785
+
786
+ try:
787
+ if os.geteuid() != 0:
788
+ # Re-run the script with sudo needed and opted
789
+ rerun_module_as_root('dwipe.main')
790
+
791
+ dwipe = DiskWipe(opts=opts)
792
+ dwipe.dev_info = info = DeviceInfo(opts=opts)
793
+ dwipe.partitions = info.assemble_partitions()
794
+ if dwipe.DB:
795
+ sys.exit(1)
796
+
797
+ dwipe.main_loop()
798
+ except Exception as exce:
799
+ if dwipe and dwipe.win:
800
+ dwipe.win.stop_curses()
801
+ print("exception:", str(exce))
802
+ print(traceback.format_exc())
803
+ sys.exit(15)
804
+
805
+ if __name__ == "__main__":
806
+ main()