dwipe 2.0.1__py3-none-any.whl → 3.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/DeviceChangeMonitor.py +244 -0
- dwipe/DeviceInfo.py +703 -177
- dwipe/DeviceWorker.py +566 -0
- dwipe/DiskWipe.py +953 -214
- dwipe/DrivePreChecker.py +203 -0
- dwipe/FirmwareWipeTask.py +865 -0
- dwipe/NvmeTool.py +225 -0
- dwipe/PersistentState.py +45 -16
- dwipe/Prereqs.py +84 -0
- dwipe/SataTool.py +499 -0
- dwipe/StructuredLogger.py +644 -0
- dwipe/Tunables.py +62 -0
- dwipe/Utils.py +298 -3
- dwipe/VerifyTask.py +412 -0
- dwipe/WipeJob.py +631 -171
- dwipe/WipeTask.py +150 -0
- dwipe/WriteTask.py +402 -0
- dwipe/main.py +34 -9
- dwipe-3.0.0.dist-info/METADATA +566 -0
- dwipe-3.0.0.dist-info/RECORD +24 -0
- dwipe/ToolManager.py +0 -637
- dwipe/WipeJobFuture.py +0 -245
- dwipe-2.0.1.dist-info/METADATA +0 -410
- dwipe-2.0.1.dist-info/RECORD +0 -14
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/WHEEL +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/entry_points.txt +0 -0
- {dwipe-2.0.1.dist-info → dwipe-3.0.0.dist-info}/licenses/LICENSE +0 -0
dwipe/NvmeTool.py
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
import subprocess
|
|
4
|
+
import io
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
from types import SimpleNamespace
|
|
8
|
+
from .Utils import Utils
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class NvmeTool:
|
|
12
|
+
"""NVMe equivalent of SataTool using nvme-cli."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, device_name, timeout=10):
|
|
15
|
+
self.timeout = timeout
|
|
16
|
+
# Handle /dev/nvme0n1 or nvme0n1
|
|
17
|
+
if device_name.startswith('/dev/'):
|
|
18
|
+
self.device_name = device_name[len('/dev/'):]
|
|
19
|
+
self.device_path = device_name
|
|
20
|
+
else:
|
|
21
|
+
self.device_name = device_name
|
|
22
|
+
self.device_path = f'/dev/{self.device_name}'
|
|
23
|
+
|
|
24
|
+
self.caps = None # Stores parsed sanitize/format capabilities
|
|
25
|
+
self.job = SimpleNamespace()
|
|
26
|
+
self.job.process = None
|
|
27
|
+
self.job.wipe_started_mono = None
|
|
28
|
+
self.job.est_secs = 60 # NVMe is usually much faster than SATA
|
|
29
|
+
self.last_command = None # Store the last command executed
|
|
30
|
+
|
|
31
|
+
def run_nvme_cmd(self, args, json_out=True):
|
|
32
|
+
"""Helper to run nvme-cli commands."""
|
|
33
|
+
cmd = ['nvme'] + args
|
|
34
|
+
if json_out:
|
|
35
|
+
cmd += ['-o', 'json']
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=self.timeout)
|
|
39
|
+
if json_out and res.stdout:
|
|
40
|
+
return json.loads(res.stdout)
|
|
41
|
+
return res
|
|
42
|
+
except Exception as e:
|
|
43
|
+
return None
|
|
44
|
+
|
|
45
|
+
def get_supported_lbaf(self):
|
|
46
|
+
"""Get a supported LBA format index.
|
|
47
|
+
|
|
48
|
+
Queries the namespace to find supported LBA formats and returns
|
|
49
|
+
one that should work. Returns the currently active format as first choice.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
int: LBAF index (0-15)
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
# Query namespace
|
|
56
|
+
data = self.run_nvme_cmd(['id-ns', self.device_path])
|
|
57
|
+
if data:
|
|
58
|
+
# Get currently active format
|
|
59
|
+
flbas = data.get('flbas', 0)
|
|
60
|
+
if isinstance(flbas, dict):
|
|
61
|
+
current_lbaf = flbas.get('format', 0)
|
|
62
|
+
else:
|
|
63
|
+
current_lbaf = int(flbas) & 0x0F
|
|
64
|
+
|
|
65
|
+
# Try to get list of supported formats
|
|
66
|
+
lbafs = data.get('lbafs', [])
|
|
67
|
+
if lbafs and isinstance(lbafs, list):
|
|
68
|
+
# Check if current format is in supported list
|
|
69
|
+
if current_lbaf < len(lbafs):
|
|
70
|
+
return current_lbaf
|
|
71
|
+
# Otherwise return first supported format
|
|
72
|
+
if len(lbafs) > 0:
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
return current_lbaf
|
|
76
|
+
except Exception:
|
|
77
|
+
pass
|
|
78
|
+
return 0 # Default to format 0
|
|
79
|
+
|
|
80
|
+
def refresh_capabilities(self):
|
|
81
|
+
"""
|
|
82
|
+
Detects if Sanitize and Format (Crypto/Erase) are supported.
|
|
83
|
+
NVMe Sanitize: Check 'id-ctrl' for 'sanicap'
|
|
84
|
+
NVMe Format: Check 'id-ctrl' for 'fna'
|
|
85
|
+
"""
|
|
86
|
+
data = self.run_nvme_cmd(['id-ctrl', self.device_path])
|
|
87
|
+
caps = SimpleNamespace(
|
|
88
|
+
has_sanitize=False,
|
|
89
|
+
crypto_erase_supported=False,
|
|
90
|
+
block_erase_supported=False,
|
|
91
|
+
overwrite_supported=False,
|
|
92
|
+
format_supported=False,
|
|
93
|
+
format_crypto_supported=False,
|
|
94
|
+
raw_data=data
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if data:
|
|
98
|
+
# Sanitize Capabilities (sanicap)
|
|
99
|
+
has_san, crypt, block, ovwr = Utils.parse_nvme_sanitize_flags(data)
|
|
100
|
+
caps.has_sanitize = has_san
|
|
101
|
+
caps.crypto_erase_supported = crypt
|
|
102
|
+
caps.block_erase_supported = block
|
|
103
|
+
caps.overwrite_supported = ovwr
|
|
104
|
+
|
|
105
|
+
# Optional NVM Command Support (oncs)
|
|
106
|
+
# Bit 2: Format NVM command is supported
|
|
107
|
+
oncs = data.get('oncs', 0)
|
|
108
|
+
caps.format_supported = bool(oncs & 0x04)
|
|
109
|
+
|
|
110
|
+
# Format Capabilities (fna)
|
|
111
|
+
# Bit 2 indicates if Crypto Erase is supported via Format command
|
|
112
|
+
fna = data.get('fna', 0)
|
|
113
|
+
caps.format_crypto_supported = bool(fna & 0x04) and caps.format_supported
|
|
114
|
+
|
|
115
|
+
self.caps = caps
|
|
116
|
+
return caps
|
|
117
|
+
|
|
118
|
+
def get_wipe_verdict(self, method='sanitize_block'):
|
|
119
|
+
"""Determines if the drive can be wiped with the specified method.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
method: Wipe method ('sanitize_block', 'sanitize_crypto', 'sanitize_overwrite',
|
|
123
|
+
'format_erase', 'format_crypto')
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
'OK' if supported, or error reason string
|
|
127
|
+
"""
|
|
128
|
+
if not self.caps:
|
|
129
|
+
self.refresh_capabilities()
|
|
130
|
+
|
|
131
|
+
# Check if the specific method is supported
|
|
132
|
+
if method == 'sanitize_block':
|
|
133
|
+
if not self.caps.block_erase_supported:
|
|
134
|
+
return "Block Erase not supported"
|
|
135
|
+
elif method == 'sanitize_crypto':
|
|
136
|
+
if not self.caps.crypto_erase_supported:
|
|
137
|
+
return "Crypto Erase not supported"
|
|
138
|
+
elif method == 'sanitize_overwrite':
|
|
139
|
+
if not self.caps.overwrite_supported:
|
|
140
|
+
return "Overwrite not supported"
|
|
141
|
+
elif method in ('format_erase', 'format_crypto'):
|
|
142
|
+
if not self.caps.format_supported:
|
|
143
|
+
return "Format not supported"
|
|
144
|
+
else:
|
|
145
|
+
return f"Unknown method: {method}"
|
|
146
|
+
|
|
147
|
+
# NVMe drives don't 'freeze' like SATA, but they can be Read-Only
|
|
148
|
+
# or have Namespace management locks.
|
|
149
|
+
return "OK"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def start_wipe(self, method='format_crypto'):
|
|
154
|
+
ctrl_path = re.sub(r'n\d+$', '', self.device_path)
|
|
155
|
+
|
|
156
|
+
# 1. Determine the Best Command
|
|
157
|
+
# If the drive supports Sanitize, it's MUCH more 'general' than Format
|
|
158
|
+
if method.startswith('sanitize'):
|
|
159
|
+
mode_map = {'sanitize_block': '2',
|
|
160
|
+
'sanitize_crypto': '4',
|
|
161
|
+
'sanitize_overwrite': '3'}
|
|
162
|
+
cmd = ['nvme', 'sanitize', '-a', mode_map[method], self.device_path]
|
|
163
|
+
else:
|
|
164
|
+
# For 'Format', we send the BARE MINIMUM.
|
|
165
|
+
# No --lbaf, no --ms, no --pi.
|
|
166
|
+
# This forces the drive to use its current valid hardware configuration.
|
|
167
|
+
ses_type = '2' if method == 'format_crypto' else '1'
|
|
168
|
+
cmd = [
|
|
169
|
+
'nvme', 'format', ctrl_path,
|
|
170
|
+
'--namespace-id=0xffffffff', # Target all namespaces (prevents locks)
|
|
171
|
+
f'--ses={ses_type}',
|
|
172
|
+
'--force'
|
|
173
|
+
]
|
|
174
|
+
|
|
175
|
+
# Store command for logging in task summary
|
|
176
|
+
self.last_command = cmd
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
# We run synchronously for the format command because it's fast
|
|
180
|
+
# and we need to check for that 0x410a immediately.
|
|
181
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
|
182
|
+
|
|
183
|
+
# FALLBACK: If 0xffffffff (all namespaces) is rejected, try just the specific one
|
|
184
|
+
if res.returncode != 0 and '0x410a' in (res.stdout + res.stderr):
|
|
185
|
+
nsid_match = re.search(r'n(\d+)$', self.device_path)
|
|
186
|
+
nsid = nsid_match.group(1) if nsid_match else "1"
|
|
187
|
+
cmd[3] = f'--namespace-id={nsid}'
|
|
188
|
+
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
|
189
|
+
|
|
190
|
+
# 2. Wrap the result to fix your 'AttributeError'
|
|
191
|
+
# We use io.StringIO so that .read() works in your Task manager
|
|
192
|
+
self.job.process = SimpleNamespace(
|
|
193
|
+
poll=lambda: 0, # Marks it as finished
|
|
194
|
+
returncode=res.returncode,
|
|
195
|
+
stdout=io.StringIO(res.stdout),
|
|
196
|
+
stderr=io.StringIO(res.stderr),
|
|
197
|
+
communicate=lambda: (res.stdout, res.stderr)
|
|
198
|
+
)
|
|
199
|
+
self.job.wipe_started_mono = time.monotonic()
|
|
200
|
+
return self.job.process
|
|
201
|
+
|
|
202
|
+
except Exception as e:
|
|
203
|
+
self.job.process = SimpleNamespace(
|
|
204
|
+
poll=lambda: 1,
|
|
205
|
+
returncode=1,
|
|
206
|
+
stdout=io.StringIO(""),
|
|
207
|
+
stderr=io.StringIO(str(e)),
|
|
208
|
+
communicate=lambda: ("", str(e))
|
|
209
|
+
)
|
|
210
|
+
return self.job.process
|
|
211
|
+
|
|
212
|
+
def get_sanitize_status(self):
|
|
213
|
+
"""
|
|
214
|
+
NVMe Sanitize runs in the background.
|
|
215
|
+
We must poll 'sanitize-log' to see if it's actually done.
|
|
216
|
+
"""
|
|
217
|
+
log = self.run_nvme_cmd(['sanitize-log', self.device_path])
|
|
218
|
+
if log:
|
|
219
|
+
# sstat: 0=Idle, 1=In Progress, 2=Success
|
|
220
|
+
status = log.get('sstat', 0) & 0x7
|
|
221
|
+
progress = log.get('sprog', 0) # 65535 = 100%
|
|
222
|
+
percent = (progress / 65535.0) * 100
|
|
223
|
+
return status, percent
|
|
224
|
+
return None, 0
|
|
225
|
+
|
dwipe/PersistentState.py
CHANGED
|
@@ -9,7 +9,7 @@ from .Utils import Utils
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
class PersistentState:
|
|
12
|
-
"""Manages persistent state for dwipe preferences and device
|
|
12
|
+
"""Manages persistent state for dwipe preferences and device blocks"""
|
|
13
13
|
|
|
14
14
|
def __init__(self, config_path=None):
|
|
15
15
|
"""Initialize persistent state
|
|
@@ -24,15 +24,14 @@ class PersistentState:
|
|
|
24
24
|
self.config_path = Path(config_path)
|
|
25
25
|
self.state = {
|
|
26
26
|
'theme': 'default',
|
|
27
|
-
'wipe_mode': '
|
|
28
|
-
'passes': 1, # 1, 2, or 4 wipe
|
|
29
|
-
'
|
|
30
|
-
'verify_pct': 2, # 0, 2, 5, 10, 25, 50, 100
|
|
27
|
+
'wipe_mode': '+V', # '-V' (no verify) or '+V' (verify after wipe) [default: +V]
|
|
28
|
+
'passes': 1, # 1, 2, or 4 wipe passes [default: 1]
|
|
29
|
+
'verify_pct': 1, # Verification percentage: 1, 3, 10, 30, 100 [default: 1]
|
|
31
30
|
'dense': False, # True = compact view, False = blank lines between disks
|
|
32
|
-
'slowdown_stop':
|
|
33
|
-
'stall_timeout': 60,
|
|
34
|
-
'port_serial':
|
|
35
|
-
'devices': {} # device_id -> {
|
|
31
|
+
'slowdown_stop': 64, # Stop if disk slows (0=disabled, else ms interval) [default: 64]
|
|
32
|
+
'stall_timeout': 60, # Stall timeout in seconds (0=disabled) [default: 60]
|
|
33
|
+
'port_serial': 'Auto', # Show port/serial info: Auto, On, Off [default: Auto]
|
|
34
|
+
'devices': {} # device_id -> {blocked, last_seen, last_name, size_bytes}
|
|
36
35
|
}
|
|
37
36
|
self.dirty = False
|
|
38
37
|
self.max_devices = 400
|
|
@@ -57,12 +56,36 @@ class PersistentState:
|
|
|
57
56
|
setattr(opts, key, self.state[key])
|
|
58
57
|
|
|
59
58
|
def load(self):
|
|
60
|
-
"""Load state from disk"""
|
|
59
|
+
"""Load state from disk, handling schema upgrades/downgrades"""
|
|
61
60
|
if self.config_path.exists():
|
|
62
61
|
try:
|
|
62
|
+
# Remember valid keys from default state
|
|
63
|
+
valid_keys = set(self.state.keys())
|
|
64
|
+
|
|
63
65
|
with open(self.config_path, 'r', encoding='utf-8') as f:
|
|
64
66
|
loaded = json.load(f)
|
|
65
|
-
|
|
67
|
+
|
|
68
|
+
# Only load keys that exist in current schema
|
|
69
|
+
for key in valid_keys:
|
|
70
|
+
if key in loaded:
|
|
71
|
+
self.state[key] = loaded[key]
|
|
72
|
+
|
|
73
|
+
# Check for obsolete keys that need removal
|
|
74
|
+
obsolete_keys = set(loaded.keys()) - valid_keys
|
|
75
|
+
if obsolete_keys:
|
|
76
|
+
self.dirty = True # Trigger save to clean up
|
|
77
|
+
|
|
78
|
+
# Migrate old wipe_mode values to new format
|
|
79
|
+
old_wipe_mode = self.state.get('wipe_mode', '+V')
|
|
80
|
+
if old_wipe_mode not in ['+V', '-V']:
|
|
81
|
+
# Old format: 'Zero', 'Zero+V', 'Rand', 'Rand+V'
|
|
82
|
+
# Convert to new format: '+V' or '-V'
|
|
83
|
+
if '+V' in str(old_wipe_mode):
|
|
84
|
+
self.state['wipe_mode'] = '+V'
|
|
85
|
+
else:
|
|
86
|
+
self.state['wipe_mode'] = '-V'
|
|
87
|
+
self.dirty = True # Save the migration
|
|
88
|
+
|
|
66
89
|
except (json.JSONDecodeError, IOError) as e:
|
|
67
90
|
print(f'Warning: Could not load state from {self.config_path}: {e}')
|
|
68
91
|
|
|
@@ -134,24 +157,28 @@ class PersistentState:
|
|
|
134
157
|
return f'fallback:{fallback_id}'
|
|
135
158
|
|
|
136
159
|
def get_device_locked(self, partition):
|
|
137
|
-
"""Check if a device is locked
|
|
160
|
+
"""Check if a device is blocked (backward compatible with 'locked')
|
|
138
161
|
|
|
139
162
|
Args:
|
|
140
163
|
partition: SimpleNamespace with device info
|
|
141
164
|
|
|
142
165
|
Returns:
|
|
143
|
-
bool: True if device is
|
|
166
|
+
bool: True if device is blocked
|
|
144
167
|
"""
|
|
145
168
|
device_id = self.make_device_id(partition)
|
|
146
169
|
device_state = self.state['devices'].get(device_id, {})
|
|
170
|
+
|
|
171
|
+
# Check new 'blocked' field first, fall back to old 'locked' field for backward compatibility
|
|
172
|
+
if 'blocked' in device_state:
|
|
173
|
+
return device_state['blocked']
|
|
147
174
|
return device_state.get('locked', False)
|
|
148
175
|
|
|
149
176
|
def set_device_locked(self, partition, locked):
|
|
150
|
-
"""Set device
|
|
177
|
+
"""Set device block state
|
|
151
178
|
|
|
152
179
|
Args:
|
|
153
180
|
partition: SimpleNamespace with device info
|
|
154
|
-
locked: bool, True to
|
|
181
|
+
locked: bool, True to block device (parameter name kept for API compatibility)
|
|
155
182
|
"""
|
|
156
183
|
device_id = self.make_device_id(partition)
|
|
157
184
|
now = int(time.time())
|
|
@@ -160,7 +187,9 @@ class PersistentState:
|
|
|
160
187
|
self.state['devices'][device_id] = {}
|
|
161
188
|
|
|
162
189
|
device_state = self.state['devices'][device_id]
|
|
163
|
-
device_state['
|
|
190
|
+
device_state['blocked'] = locked # Only save 'blocked', not 'locked'
|
|
191
|
+
# Remove old 'locked' field if it exists (gradual migration)
|
|
192
|
+
device_state.pop('locked', None)
|
|
164
193
|
device_state['last_seen'] = now
|
|
165
194
|
device_state['last_name'] = partition.name
|
|
166
195
|
device_state['size_bytes'] = partition.size_bytes
|
dwipe/Prereqs.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
import shutil
|
|
3
|
+
import sys
|
|
4
|
+
import os
|
|
5
|
+
from typing import Optional, Dict, List
|
|
6
|
+
|
|
7
|
+
class Prereqs:
|
|
8
|
+
"""Manages tool dependencies and provides actionable install suggestions."""
|
|
9
|
+
|
|
10
|
+
# Mapping tool binary to the actual package name for different managers
|
|
11
|
+
TOOL_MAP = {
|
|
12
|
+
'lsblk': {
|
|
13
|
+
'apt': 'util-linux', 'dnf': 'util-linux', 'pacman': 'util-linux',
|
|
14
|
+
'zypper': 'util-linux', 'apk': 'util-linux'
|
|
15
|
+
},
|
|
16
|
+
'hdparm': {
|
|
17
|
+
'apt': 'hdparm', 'dnf': 'hdparm', 'pacman': 'hdparm',
|
|
18
|
+
'zypper': 'hdparm', 'apk': 'hdparm'
|
|
19
|
+
},
|
|
20
|
+
'nvme': {
|
|
21
|
+
'apt': 'nvme-cli', 'dnf': 'nvme-cli', 'pacman': 'nvme-cli',
|
|
22
|
+
'zypper': 'nvme-cli', 'apk': 'nvme-cli'
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
def __init__(self, verbose: bool = False):
|
|
27
|
+
self.verbose = verbose
|
|
28
|
+
self.pm = self._detect_package_manager()
|
|
29
|
+
# Track status of required tools
|
|
30
|
+
self.results = {}
|
|
31
|
+
|
|
32
|
+
def _detect_package_manager(self) -> Optional[str]:
|
|
33
|
+
managers = ['apt', 'dnf', 'yum', 'pacman', 'zypper', 'apk', 'brew']
|
|
34
|
+
for cmd in managers:
|
|
35
|
+
if shutil.which(cmd):
|
|
36
|
+
return cmd
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
def check_all(self, tools: List[str]):
|
|
40
|
+
"""Runs the check for a list of tools."""
|
|
41
|
+
for tool in tools:
|
|
42
|
+
path = shutil.which(tool)
|
|
43
|
+
self.results[tool] = path is not None
|
|
44
|
+
return all(self.results.values())
|
|
45
|
+
|
|
46
|
+
def get_install_hint(self, tool: str) -> str:
|
|
47
|
+
"""Returns a string suggesting how to fix the missing tool."""
|
|
48
|
+
if not self.pm:
|
|
49
|
+
return "Please install via your system's package manager."
|
|
50
|
+
|
|
51
|
+
# Get package name (default to tool name if mapping missing)
|
|
52
|
+
pkg = self.TOOL_MAP.get(tool, {}).get(self.pm, tool)
|
|
53
|
+
|
|
54
|
+
commands = {
|
|
55
|
+
'apt': f"sudo apt update && sudo apt install {pkg}",
|
|
56
|
+
'dnf': f"sudo dnf install {pkg}",
|
|
57
|
+
'yum': f"sudo yum install {pkg}",
|
|
58
|
+
'pacman': f"sudo pacman -S {pkg}",
|
|
59
|
+
'zypper': f"sudo zypper install {pkg}",
|
|
60
|
+
'apk': f"apk add {pkg}",
|
|
61
|
+
'brew': f"brew install {pkg}"
|
|
62
|
+
}
|
|
63
|
+
return commands.get(self.pm, f"Use {self.pm} to install {pkg}")
|
|
64
|
+
|
|
65
|
+
def report_and_exit_if_failed(self):
|
|
66
|
+
"""Prints a clean summary to stdout. Exits if critical tools are missing."""
|
|
67
|
+
print("\n--- System Prerequisite Check ---")
|
|
68
|
+
failed = False
|
|
69
|
+
|
|
70
|
+
for tool, available in self.results.items():
|
|
71
|
+
mark = "✓" if available else "✗"
|
|
72
|
+
status = "FOUND" if available else "MISSING"
|
|
73
|
+
print(f" {mark} {tool:<10} : {status}")
|
|
74
|
+
|
|
75
|
+
if not available:
|
|
76
|
+
failed = True
|
|
77
|
+
print(f" └─ Suggestion: {self.get_install_hint(tool)}")
|
|
78
|
+
|
|
79
|
+
if failed:
|
|
80
|
+
print("\nERROR: Dwipe cannot start until all prerequisites are met.\n")
|
|
81
|
+
sys.exit(1)
|
|
82
|
+
|
|
83
|
+
if self.verbose:
|
|
84
|
+
print("All prerequisites satisfied.\n")
|