Skip to content

Commit bd7e5cb

Browse files
committed
Ok
Please enter the commit message for your changes. Lines starting with '' will be ignored, and an empty message aborts the commit. On branch less-deps-feature-win-tweaks Your branch is up to date with 'origin/less-deps-feature-win-tweaks'. Changes to be committed: modified: src/lufus/drives/find_usb.py modified: src/lufus/drives/get_usb_info.py modified: src/lufus/writing/flash_usb.py modified: tests/test_flash_usb_and_find_usb_fixes.py modified: tests/test_get_usb_info.py Untracked files: tests/conftest.py
1 parent 3a58e46 commit bd7e5cb

5 files changed

Lines changed: 221 additions & 68 deletions

File tree

src/lufus/drives/find_usb.py

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,16 @@ def _media_directories() -> list[str]:
3535

3636

3737
### USB RECOGNITION ###
38+
def _get_device_from_node(context: pyudev.Context, device_node: str) -> pyudev.Device | None:
39+
"""Helper to resolve a device node to a pyudev device."""
40+
try:
41+
st = os.stat(device_node)
42+
return pyudev.Devices.from_device_number(context, "block", st.st_rdev)
43+
except Exception as err:
44+
log.debug("Could not resolve device %s via udev: %s", device_node, err)
45+
return None
46+
47+
3848
def find_usb() -> dict[str, str]:
3949
"""Return a mapping of mount-path -> volume-label for detected USB drives."""
4050
usbdict = {} # DICTIONARY WHERE USB MOUNT PATH IS KEY AND LABEL IS VALUE
@@ -52,14 +62,8 @@ def find_usb() -> dict[str, str]:
5262
if not device_node:
5363
continue
5464

55-
label = None
56-
try:
57-
# Using os.stat to get device number as per requirements
58-
st = os.stat(device_node)
59-
device = pyudev.Devices.from_device_number(context, "block", st.st_rdev)
60-
label = device.get("ID_FS_LABEL")
61-
except Exception:
62-
pass
65+
device = _get_device_from_node(context, device_node)
66+
label = device.get("ID_FS_LABEL") if device else None
6367

6468
if not label:
6569
label = os.path.basename(mount_path)

src/lufus/drives/get_usb_info.py

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,16 @@ class USBDeviceInfo(TypedDict):
1313
mount_path: str
1414

1515

16+
def _get_device_from_node(context: pyudev.Context, device_node: str) -> pyudev.Device | None:
17+
"""Helper to resolve a device node to a pyudev device."""
18+
try:
19+
st = os.stat(device_node)
20+
return pyudev.Devices.from_device_number(context, "block", st.st_rdev)
21+
except Exception as err:
22+
log.debug("Could not resolve device %s via udev: %s", device_node, err)
23+
return None
24+
25+
1626
def get_usb_info(usb_path: str) -> USBDeviceInfo | None:
1727
try:
1828
normalized_usb_path = os.path.normpath(usb_path)
@@ -26,13 +36,20 @@ def get_usb_info(usb_path: str) -> USBDeviceInfo | None:
2636
return None
2737

2838
context = pyudev.Context()
29-
# Using os.stat to get device number as per requirements
30-
st = os.stat(device_node)
31-
device = pyudev.Devices.from_device_number(context, "block", st.st_rdev)
39+
device = _get_device_from_node(context, device_node)
40+
if not device:
41+
return None
3242

3343
# Size in bytes: udev attributes 'size' is in 512-byte sectors
3444
size_attr = device.attributes.get("size")
35-
usb_size = int(size_attr) * 512 if size_attr else 0
45+
try:
46+
usb_size = int(size_attr) * 512 if size_attr is not None else 0
47+
except (ValueError, TypeError):
48+
log.warning(
49+
"Unexpected non-numeric udev size attribute %r; defaulting USB size to 0",
50+
size_attr,
51+
)
52+
usb_size = 0
3653

3754
label = device.get("ID_FS_LABEL")
3855

src/lufus/writing/flash_usb.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -116,19 +116,31 @@ def _status(msg: str) -> None:
116116

117117
_status(f"dd process started with PID {process.pid}")
118118

119+
# Use non-blocking read for stderr to handle \r updates without blocking
120+
os.set_blocking(process.stderr.fileno(), False)
121+
119122
buf = b""
120123
last_pct = -1
121124
while True:
122-
# Read in small chunks to handle \r progress updates from dd without blocking
123-
# until a newline (\n) is received. status=progress usually emits \r.
124125
try:
125-
chunk = process.stderr.read(128)
126+
chunk = process.stderr.read(4096)
126127
except Exception as e:
127128
log.warning("Error reading dd stderr: %s", e)
128129
break
129130

131+
if chunk is None:
132+
# No data yet, wait a bit
133+
import time
134+
time.sleep(0.1)
135+
if process.poll() is not None:
136+
break
137+
continue
138+
130139
if not chunk:
131-
break
140+
if process.poll() is not None:
141+
break
142+
continue
143+
132144
buf += chunk
133145
# Split by \r or \n to catch all progress updates
134146
parts = re.split(rb"[\r\n]", buf)

tests/test_flash_usb_and_find_usb_fixes.py

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import sys
1111
from pathlib import Path
1212
from types import SimpleNamespace
13+
from unittest.mock import MagicMock
1314

1415
ROOT = Path(__file__).resolve().parents[1]
1516
SRC = ROOT / "src"
@@ -145,10 +146,25 @@ def __init__(self, args, **kwargs):
145146
def wait(self):
146147
pass
147148

149+
def poll(self):
150+
return 0
151+
152+
def __enter__(self):
153+
return self
154+
155+
def __exit__(self, exc_type, exc_val, exc_tb):
156+
pass
157+
148158
class FakePipe:
149159
def readline(self):
150160
return b""
151161

162+
def fileno(self):
163+
return 3
164+
165+
def read(self, size):
166+
return b""
167+
152168
monkeypatch.setattr(flash_usb_module.subprocess, "Popen", FakeProcess)
153169

154170
flash_usb("/dev/nvme0n1p1", str(iso))
@@ -362,3 +378,132 @@ def test_find_dn_returns_device_node(self, monkeypatch):
362378
)
363379

364380
assert find_usb_module.find_device_node() == "/dev/sdd1"
381+
382+
class TestFlashUsbProgress:
383+
def _fake_popen_factory(self, stderr_chunks, popen_calls, envs):
384+
class FakePopen:
385+
def __init__(self, cmd, **kwargs):
386+
popen_calls.append((cmd, kwargs))
387+
envs.append(kwargs.get("env"))
388+
self.cmd = cmd
389+
self.kwargs = kwargs
390+
self.pid = 999
391+
self.returncode = 0
392+
self.stderr = MagicMock()
393+
self.stderr.fileno.return_value = 3
394+
395+
# Generator for non-blocking read simulator
396+
def chunk_generator():
397+
for chunk in stderr_chunks:
398+
yield chunk.encode("utf-8")
399+
while True:
400+
yield b""
401+
402+
self.gen = chunk_generator()
403+
404+
def poll(self):
405+
return self.returncode
406+
407+
def wait(self):
408+
return self.returncode
409+
410+
@property
411+
def stderr_mock(self):
412+
# This matches the read(4096) call in flash_usb
413+
def read_mock(size):
414+
try:
415+
val = next(self.gen)
416+
return val if val else None # simulate non-blocking "None" for empty
417+
except StopIteration:
418+
return b""
419+
return read_mock
420+
421+
return FakePopen
422+
423+
def test_progress_cb_receives_milestones_and_scaled_dd_progress(self, monkeypatch):
424+
progress_values = []
425+
status_messages = []
426+
427+
def progress_cb(progress):
428+
progress_values.append(progress)
429+
430+
def status_cb(status):
431+
status_messages.append(status)
432+
433+
# 100MB ISO
434+
iso_size = 100 * 1024 * 1024
435+
stderr_chunks = [
436+
"0+0 records in\r",
437+
"0+0 records out\r",
438+
"10485760 bytes (10 MB) copied\r", # 10%
439+
"52428800 bytes (52 MB) copied\r", # 50%
440+
"104857600 bytes (105 MB) copied\n", # 100%
441+
]
442+
443+
popen_calls = []
444+
envs = []
445+
446+
# We need to mock os.path.getsize, check_iso_signature, detect_iso_type, os.set_blocking
447+
monkeypatch.setattr(flash_usb_module.os.path, "getsize", lambda p: iso_size)
448+
monkeypatch.setattr(flash_usb_module, "check_iso_signature", lambda p: True)
449+
monkeypatch.setattr(flash_usb_module, "detect_iso_type", lambda p: flash_usb_module.IsoType.LINUX)
450+
monkeypatch.setattr(flash_usb_module.os, "set_blocking", lambda fd, block: None)
451+
452+
fake_popen_class = self._fake_popen_factory(stderr_chunks, popen_calls, envs)
453+
454+
# We must patch subprocess.Popen where it is used in flash_usb_module
455+
monkeypatch.setattr(flash_usb_module.subprocess, "Popen",
456+
lambda cmd, **kwargs: (p := fake_popen_class(cmd, **kwargs),
457+
setattr(p.stderr, 'read', p.stderr_mock), p)[2])
458+
459+
import os
460+
original_environ = dict(os.environ)
461+
462+
flash_usb("/dev/sdb", "/path/to/image.iso", progress_cb=progress_cb, status_cb=status_cb)
463+
464+
assert envs[0].get("LC_ALL") == "C"
465+
assert dict(os.environ) == original_environ
466+
467+
# Milestones: 2, 5, 8, 10
468+
assert 2 in progress_values
469+
assert 5 in progress_values
470+
assert 8 in progress_values
471+
assert 10 in progress_values
472+
473+
# 10% raw -> 10 + 10*0.85 = 18.5 -> 18
474+
# 50% raw -> 10 + 50*0.85 = 52.5 -> 52
475+
# 100% raw -> 10 + 100*0.85 = 95
476+
assert 18 in progress_values
477+
assert 52 in progress_values
478+
assert 95 in progress_values
479+
480+
assert any("10%" in msg for msg in status_messages)
481+
assert any("50%" in msg for msg in status_messages)
482+
483+
def test_non_progress_and_unexpected_stderr_lines_handling(self, monkeypatch, caplog):
484+
def progress_cb(p): pass
485+
def status_cb(s): pass
486+
487+
iso_size = 1000
488+
stderr_chunks = [
489+
"1+0 records in\n",
490+
"some unexpected warning from dd\n",
491+
]
492+
493+
popen_calls = []
494+
envs = []
495+
496+
monkeypatch.setattr(flash_usb_module.os.path, "getsize", lambda p: iso_size)
497+
monkeypatch.setattr(flash_usb_module, "check_iso_signature", lambda p: True)
498+
monkeypatch.setattr(flash_usb_module, "detect_iso_type", lambda p: flash_usb_module.IsoType.LINUX)
499+
monkeypatch.setattr(flash_usb_module.os, "set_blocking", lambda fd, block: None)
500+
501+
fake_popen_class = self._fake_popen_factory(stderr_chunks, popen_calls, envs)
502+
monkeypatch.setattr(flash_usb_module.subprocess, "Popen",
503+
lambda cmd, **kwargs: (p := fake_popen_class(cmd, **kwargs),
504+
setattr(p.stderr, 'read', p.stderr_mock), p)[2])
505+
506+
with caplog.at_level("WARNING"):
507+
flash_usb("/dev/sdb", "/path/to/image.iso", progress_cb=progress_cb, status_cb=status_cb)
508+
509+
assert any("some unexpected warning from dd" in record.getMessage() for record in caplog.records)

0 commit comments

Comments
 (0)