Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
732856d
Tests must be fixed. Pwease.
R-Larocque May 7, 2026
205d3b3
FIXED! Tests works, and LSBLK is replaced w/Python native tooling1
R-Larocque May 7, 2026
f77fd1c
Issue #178 fixed.
R-Larocque May 7, 2026
dc34beb
Please enter the commit message for your changes. Lines starting
R-Larocque May 7, 2026
62f15d5
Please enter the commit message for your changes. Lines starting
R-Larocque May 7, 2026
5d53b8f
Revert " Please enter the commit message for your changes. Lines star…
R-Larocque May 7, 2026
79817e8
Revert "Issue 178 fixed."
R-Larocque May 7, 2026
cb60160
Please enter the commit message for your changes. Lines starting
R-Larocque May 7, 2026
a8a066f
Please enter the commit message for your changes. Lines starting
R-Larocque May 7, 2026
f6fc2a3
Please enter the commit message for your changes. Lines starting
R-Larocque May 7, 2026
dbc3e64
Please enter the commit message for your changes. Lines starting
R-Larocque May 7, 2026
d4f7118
Proceeding to fix goofy encoding issues
R-Larocque May 7, 2026
09fe322
Please enter the commit message for your changes. Lines starting
R-Larocque May 7, 2026
f30d249
Veuillez saisir le message de validation pour vos modifications. Les…
R-Larocque May 7, 2026
7505cc9
Veuillez saisir le message de validation pour vos modifications. Les…
R-Larocque May 7, 2026
8791cf1
Fixed detection, lets see if shit works now...
R-Larocque May 7, 2026
41eff88
Veuillez saisir le message de validation pour vos modifications. Les…
R-Larocque May 7, 2026
f227f74
Add pyudev to Python Compatibility workflow
R-Larocque May 7, 2026
2ece545
Fix test FakeStderr to use read1() for progress parsing
R-Larocque May 7, 2026
15068d3
Veuillez saisir le message de validation pour vos modifications. Les…
R-Larocque May 7, 2026
342683c
Veuillez saisir le message de validation pour vos modifications. Les…
R-Larocque May 7, 2026
d39ecee
Veuillez saisir le message de validation pour vos modifications. Les…
R-Larocque May 7, 2026
9f94364
Ruff format
R-Larocque May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ jobs:
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install psutil pytest
pip install psutil pyudev pytest

- name: Install package (no-deps to avoid GUI reqs)
run: pip install -e . --no-deps
Expand Down
23 changes: 5 additions & 18 deletions .github/workflows/python-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ on:

jobs:
test:
name: "Test — Python ${{ matrix.python-version }}"
Comment on lines 11 to -12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Job name no longer differentiates between Python versions in the matrix

The static name makes all matrix jobs look identical in the Actions UI, which makes it harder to see which Python version failed. Please reintroduce the version in the job name (e.g., Run Tests (Python ${{ matrix.python-version }})) so each matrix entry is distinguishable.

Suggested change
test:
name: "Test — Python ${{ matrix.python-version }}"
test:
name: "Run Tests (Python ${{ matrix.python-version }})"

name: "Run Tests (Python 3.10+)"
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14-dev"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question (testing): Reducing the Python matrix may drop coverage for supported runtimes.

This change speeds up CI but drops CI coverage for 3.11 and 3.12. If we still claim support for those versions, consider keeping at least one (e.g. 3.11 or 3.12) in the matrix to help catch compatibility issues across 3.10–3.13.

python-version: ["3.10", "3.13"]

steps:
- uses: actions/checkout@v4
Expand All @@ -24,25 +24,12 @@ jobs:
with:
python-version: ${{ matrix.python-version }}

- name: Cache pip
uses: actions/cache@v4
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ matrix.python-version }}-${{ hashFiles('pyproject.toml') }}
restore-keys: |
${{ runner.os }}-pip-${{ matrix.python-version }}-

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install psutil pytest packaging platformdirs

- name: Install package (no-deps to avoid GUI reqs)
run: pip install -e . --no-deps
pip install -r requirements-python.txt
pip install pytest

- name: Run tests
run: |
pytest --maxfail=1 -q \
--ignore=src/lufus/gui \
--ignore=src/lufus/drives/autodetect_usb.py \
tests/
pytest --maxfail=1 -q tests/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

## Beta Release Disclaimer

**Lufus** is currently in **Beta**.
**Lufus** is currently in **Beta**!

Lufus is a physical drive imaging and formatting utility written in Python, inspired by **Rufus** on Windows, with the goal of delivering a greater experience for Linux users.
While core functionality has been implemented, the project is still under active development. Users should expect bugs, incomplete features, and ongoing structural changes.
Expand Down
14 changes: 7 additions & 7 deletions src/lufus/drives/find_usb.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import psutil
import os
import subprocess
import pyudev
import getpass
from lufus import state
from lufus.lufus_logging import get_logger
Expand Down Expand Up @@ -43,6 +43,7 @@ def find_usb() -> dict[str, str]:
dir_set = set(all_directories)

# Check each partition to see if it matches our potential mount points
context = pyudev.Context()
for part in psutil.disk_partitions(all=True):
if part.mountpoint not in dir_set:
continue
Expand All @@ -53,12 +54,11 @@ def find_usb() -> dict[str, str]:

label = None
try:
label = subprocess.check_output(
["lsblk", "-d", "-n", "-o", "LABEL", device_node],
text=True,
timeout=5,
).strip()
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
# Using os.stat to get device number as per requirements
st = os.stat(device_node)
device = pyudev.Devices.from_device_number(context, "block", st.st_rdev)
label = device.get("ID_FS_LABEL")
except Exception:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
pass

if not label:
Expand Down
26 changes: 10 additions & 16 deletions src/lufus/drives/get_usb_info.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import psutil
import os
import subprocess
import pyudev
from typing import TypedDict
from lufus.lufus_logging import get_logger

Expand All @@ -25,20 +25,20 @@ def get_usb_info(usb_path: str) -> USBDeviceInfo | None:
log.warning("Could not find device node for USB path: %s", usb_path)
return None

size_output = subprocess.check_output(
["lsblk", "-d", "-n", "-b", "-o", "SIZE", device_node],
text=True,
timeout=5,
).strip()
context = pyudev.Context()
# Using os.stat to get device number as per requirements
st = os.stat(device_node)
device = pyudev.Devices.from_device_number(context, "block", st.st_rdev)

usb_size = int(size_output) if size_output.isdigit() else 0
if not size_output.isdigit():
log.warning("Could not parse device size: %r", size_output)
# Size in bytes: udev attributes 'size' is in 512-byte sectors
size_attr = device.attributes.get("size")
usb_size = int(size_attr) * 512 if size_attr else 0

label = device.get("ID_FS_LABEL")
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

if usb_size > 32 * 1024**3:
log.warning("USB device is large (%d bytes); confirm before flashing.", usb_size)

label = subprocess.check_output(["lsblk", "-d", "-n", "-o", "LABEL", device_node], text=True, timeout=5).strip()
if not label:
label = os.path.basename(usb_path)

Expand All @@ -49,15 +49,9 @@ def get_usb_info(usb_path: str) -> USBDeviceInfo | None:
}
log.info("USB Info: %s", usb_info)
return usb_info
except subprocess.TimeoutExpired as e:
log.error("Timed out getting USB info for %s: %s", usb_path, e)
return None
except PermissionError:
log.error("Permission denied when trying to get USB info: %s", usb_path)
return None
except subprocess.CalledProcessError as e:
log.error("Error getting USB info: %s", e)
return None
except Exception as err:
log.error("Unexpected error getting USB info: %s", err)
return None
55 changes: 43 additions & 12 deletions tests/test_find_usb.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
from __future__ import annotations
import subprocess
import sys
import os
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
Expand All @@ -15,6 +16,7 @@
def test_find_usb_returns_mount_to_label_mapping(monkeypatch) -> None:
user = "testuser"
mount_path = f"/media/{user}/MY_USB"
device_node = "/dev/sdb1"

monkeypatch.setattr(find_usb_module.getpass, "getuser", lambda: user)
monkeypatch.setattr(
Expand All @@ -35,21 +37,36 @@ def test_find_usb_returns_mount_to_label_mapping(monkeypatch) -> None:
monkeypatch.setattr(
find_usb_module.psutil,
"disk_partitions",
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device="/dev/sdb1")],
)
monkeypatch.setattr(
find_usb_module.subprocess,
"check_output",
lambda *args, **kwargs: "lufus_USB\n",
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device=device_node)],
)

# Mock os.stat safely
os_stat_orig = os.stat

def mock_os_stat(p):
if str(p).startswith("/dev/"):
mock_stat = MagicMock()
mock_stat.st_rdev = 1234
return mock_stat
return os_stat_orig(p)

monkeypatch.setattr(find_usb_module.os, "stat", mock_os_stat)

# Mock pyudev
mock_context = MagicMock()
mock_device = MagicMock()
mock_device.get.return_value = "lufus_USB"
monkeypatch.setattr(find_usb_module.pyudev, "Context", lambda: mock_context)
monkeypatch.setattr(find_usb_module.pyudev.Devices, "from_device_number", lambda ctx, type, num: mock_device)

result = find_usb_module.find_usb()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test covering the fallback when pyudev returns an empty label rather than raising.

The current test only covers the exception path. Since the code also falls back to the directory name when label is falsy, please add a test where Devices.from_device_number succeeds but device.get("ID_FS_LABEL") returns an empty string, to explicitly cover that branch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test for pyudev returning an empty/None label (non-exceptional) to exercise the fallback path.

The new test covers the exception path from pyudev.Devices.from_device_number. Please also add a test where from_device_number succeeds but device.get("ID_FS_LABEL") returns None or "", so we verify the fallback to the directory basename for non-exceptional pyudev failures as well.

assert result == {mount_path: "lufus_USB"}


def test_find_usb_falls_back_to_dir_name_when_lsblk_fails(monkeypatch) -> None:
def test_find_usb_falls_back_to_dir_name_when_pyudev_fails(monkeypatch) -> None:
user = "testuser"
mount_path = f"/media/{user}/NO_LABEL"
device_node = "/dev/sdc1"

monkeypatch.setattr(find_usb_module.getpass, "getuser", lambda: user)
monkeypatch.setattr(
Expand All @@ -70,13 +87,27 @@ def test_find_usb_falls_back_to_dir_name_when_lsblk_fails(monkeypatch) -> None:
monkeypatch.setattr(
find_usb_module.psutil,
"disk_partitions",
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device="/dev/sdc1")],
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device=device_node)],
)

def raise_lsblk_error(*args, **kwargs):
raise subprocess.CalledProcessError(returncode=1, cmd="lsblk")
# Mock os.stat safely
os_stat_orig = os.stat

def mock_os_stat(p):
if str(p).startswith("/dev/"):
mock_stat = MagicMock()
mock_stat.st_rdev = 5678
return mock_stat
return os_stat_orig(p)

monkeypatch.setattr(find_usb_module.os, "stat", mock_os_stat)

monkeypatch.setattr(find_usb_module.subprocess, "check_output", raise_lsblk_error)
# Mock pyudev to fail
mock_context = MagicMock()
monkeypatch.setattr(find_usb_module.pyudev, "Context", lambda: mock_context)
monkeypatch.setattr(
find_usb_module.pyudev.Devices, "from_device_number", MagicMock(side_effect=Exception("udev fail"))
)

result = find_usb_module.find_usb()
assert result == {mount_path: "NO_LABEL"}
Expand Down
30 changes: 23 additions & 7 deletions tests/test_flash_usb_and_find_usb_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -285,9 +285,10 @@ def test_empty_device_node_does_not_overwrite_states_dn(self, monkeypatch):


class TestFindUsbHappyPath:
def test_find_usb_returns_label_from_lsblk(self, monkeypatch):
def test_find_usb_returns_label_from_udev(self, monkeypatch):
user = "testuser"
mount_path = f"/media/{user}/MY_USB"
device_node = "/dev/sdb1"

monkeypatch.setattr(find_usb_module.getpass, "getuser", lambda: user)
monkeypatch.setattr(
Expand All @@ -308,14 +309,29 @@ def test_find_usb_returns_label_from_lsblk(self, monkeypatch):
monkeypatch.setattr(
find_usb_module.psutil,
"disk_partitions",
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device="/dev/sdb1")],
)
monkeypatch.setattr(
find_usb_module.subprocess,
"check_output",
lambda *a, **kw: "MY_LABEL\n",
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device=device_node)],
)

import os as real_os
from unittest.mock import MagicMock

os_stat_orig = find_usb_module.os.stat

def mock_os_stat(p):
if str(p).startswith("/dev/"):
m = MagicMock()
m.st_rdev = 1234
return m
return os_stat_orig(p)

monkeypatch.setattr(find_usb_module.os, "stat", mock_os_stat)

mock_context = MagicMock()
mock_device = MagicMock()
mock_device.get.return_value = "MY_LABEL"
monkeypatch.setattr(find_usb_module.pyudev, "Context", lambda: mock_context)
monkeypatch.setattr(find_usb_module.pyudev.Devices, "from_device_number", lambda ctx, type, num: mock_device)

result = find_usb_module.find_usb()
assert result == {mount_path: "MY_LABEL"}

Expand Down
66 changes: 47 additions & 19 deletions tests/test_get_usb_info.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock

ROOT = Path(__file__).resolve().parents[1]
SRC = ROOT / "src"
Expand Down Expand Up @@ -32,14 +32,25 @@ def test_get_usb_info_returns_expected_dictionary(monkeypatch) -> None:
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device=device_node)],
)

def fake_check_output(cmd, text=True, timeout=5):
if cmd[-2:] == ["SIZE", device_node]:
return str(16 * 1024**3)
if cmd[-2:] == ["LABEL", device_node]:
return "MYUSB\n"
raise AssertionError(f"Unexpected command: {cmd}")
# Mock os.stat safely
os_stat_orig = get_usb_info_module.os.stat

monkeypatch.setattr(get_usb_info_module.subprocess, "check_output", fake_check_output)
def mock_os_stat(p):
if str(p).startswith("/dev/"):
mock_stat = MagicMock()
mock_stat.st_rdev = 1234
return mock_stat
return os_stat_orig(p)

monkeypatch.setattr(get_usb_info_module.os, "stat", mock_os_stat)

# Mock pyudev
mock_context = MagicMock()
mock_device = MagicMock()
mock_device.attributes = {"size": str((16 * 1024**3) // 512)}
mock_device.get.return_value = "MYUSB"
monkeypatch.setattr(get_usb_info_module.pyudev, "Context", lambda: mock_context)
monkeypatch.setattr(get_usb_info_module.pyudev.Devices, "from_device_number", lambda ctx, type, num: mock_device)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding a test for malformed or non-integer pyudev size attributes.

Right now the tests only cover cases where device.attributes['size'] is a valid integer string. Since the code does int(size_attr) * 512 if size_attr else 0 and catches all Exception, please add a test where size is non-numeric (e.g. 'abc') and assert the resulting behavior (likely None). This will lock in the expected handling of malformed udev metadata and avoid regressions around ValueError handling.


result = get_usb_info_module.get_usb_info(mount_path)
assert result == {
Expand All @@ -59,20 +70,31 @@ def test_get_usb_info_uses_mount_basename_when_label_is_empty(monkeypatch) -> No
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device=device_node)],
)

def fake_check_output(cmd, text=True, timeout=5):
if cmd[-2:] == ["SIZE", device_node]:
return str(8 * 1024**3)
if cmd[-2:] == ["LABEL", device_node]:
return "\n"
raise AssertionError(f"Unexpected command: {cmd}")
# Mock os.stat safely
os_stat_orig = get_usb_info_module.os.stat

def mock_os_stat(p):
if str(p).startswith("/dev/"):
mock_stat = MagicMock()
mock_stat.st_rdev = 5678
return mock_stat
return os_stat_orig(p)

monkeypatch.setattr(get_usb_info_module.subprocess, "check_output", fake_check_output)
monkeypatch.setattr(get_usb_info_module.os, "stat", mock_os_stat)

# Mock pyudev
mock_context = MagicMock()
mock_device = MagicMock()
mock_device.attributes = {"size": str((8 * 1024**3) // 512)}
mock_device.get.return_value = None
monkeypatch.setattr(get_usb_info_module.pyudev, "Context", lambda: mock_context)
monkeypatch.setattr(get_usb_info_module.pyudev.Devices, "from_device_number", lambda ctx, type, num: mock_device)

result = get_usb_info_module.get_usb_info(mount_path)
assert result["label"] == "NO_LABEL"


def test_get_usb_info_returns_empty_when_lsblk_fails(monkeypatch) -> None:
def test_get_usb_info_returns_empty_when_pyudev_fails(monkeypatch) -> None:
Comment on lines 103 to +107

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a dedicated test for PermissionError in get_usb_info to ensure that branch is covered.

Right now only the generic exception path from os.stat is tested. Please add a test that explicitly makes get_usb_info_module.os.stat raise PermissionError and verifies that get_usb_info returns None without re-raising, so the dedicated handler is covered and guarded against regressions.

Suggested change
result = get_usb_info_module.get_usb_info(mount_path)
assert result["label"] == "NO_LABEL"
def test_get_usb_info_returns_empty_when_lsblk_fails(monkeypatch) -> None:
def test_get_usb_info_returns_empty_when_pyudev_fails(monkeypatch) -> None:
result = get_usb_info_module.get_usb_info(mount_path)
assert result["label"] == "NO_LABEL"
def test_get_usb_info_returns_none_on_permission_error(monkeypatch) -> None:
mount_path = "/media/testuser/USB"
device_node = "/dev/sdb1"
monkeypatch.setattr(
get_usb_info_module.psutil,
"disk_partitions",
lambda *args, **kwargs: [
SimpleNamespace(mountpoint=mount_path, device=device_node)
],
)
def mock_os_stat(path: str) -> None:
raise PermissionError("Permission denied")
monkeypatch.setattr(get_usb_info_module.os, "stat", mock_os_stat)
result = get_usb_info_module.get_usb_info(mount_path)
assert result is None
def test_get_usb_info_returns_empty_when_pyudev_fails(monkeypatch) -> None:

mount_path = "/media/testuser/USB"
device_node = "/dev/sdb1"

Comment on lines +107 to 111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add a test where pyudev fails independently of os.stat to fully exercise the new error-handling path.

The existing test_get_usb_info_returns_empty_when_pyudev_fails only covers failures via os.stat raising. Since the code can also fail when pyudev.Devices.from_device_number or attribute access on the returned device raises, please add a test where os.stat succeeds but the pyudev lookup fails (e.g., Devices.from_device_number raises or returns a device without attributes['size']) to verify these cases also return None.

Suggested implementation:

def test_get_usb_info_returns_empty_when_pyudev_fails(monkeypatch) -> None:

Please add a new test function to cover the case where os.stat succeeds but the pyudev lookup fails independently (e.g. Devices.from_device_number raises). Place it near the other get_usb_info tests, for example directly after test_get_usb_info_returns_empty_when_pyudev_fails:

def test_get_usb_info_returns_empty_when_pyudev_device_lookup_raises(monkeypatch) -> None:
    mount_path = "/media/testuser/USB"
    device_node = "/dev/sdb1"

    # Partition for the mount path is found
    monkeypatch.setattr(
        get_usb_info_module.psutil,
        "disk_partitions",
        lambda *args, **kwargs: [
            SimpleNamespace(mountpoint=mount_path, device=device_node)
        ],
    )

    # os.stat succeeds and yields a valid st_dev
    monkeypatch.setattr(
        get_usb_info_module.os,
        "stat",
        lambda p: SimpleNamespace(st_dev=1234),
    )

    # pyudev context can be constructed, but the device lookup itself fails
    mock_context = object()
    monkeypatch.setattr(
        get_usb_info_module.pyudev,
        "Context",
        lambda: mock_context,
    )

    def failing_from_device_number(*args, **kwargs):
        raise RuntimeError("pyudev failure")

    monkeypatch.setattr(
        get_usb_info_module.pyudev.Devices,
        "from_device_number",
        failing_from_device_number,
    )

    result = get_usb_info_module.get_usb_info(mount_path)

    # When pyudev fails independently of os.stat, we should still return an empty result
    assert result is None

If your implementation also has a separate failure path when the device is found but device.attributes["size"] is missing or raises, you can add a sister test to explicitly cover that:

def test_get_usb_info_returns_empty_when_pyudev_missing_size_attribute(monkeypatch) -> None:
    mount_path = "/media/testuser/USB"
    device_node = "/dev/sdb1"

    monkeypatch.setattr(
        get_usb_info_module.psutil,
        "disk_partitions",
        lambda *args, **kwargs: [
            SimpleNamespace(mountpoint=mount_path, device=device_node)
        ],
    )

    monkeypatch.setattr(
        get_usb_info_module.os,
        "stat",
        lambda p: SimpleNamespace(st_dev=1234),
    )

    mock_context = object()
    monkeypatch.setattr(
        get_usb_info_module.pyudev,
        "Context",
        lambda: mock_context,
    )

    mock_device = SimpleNamespace()
    # Simulate missing / inaccessible size attribute
    mock_device.attributes = {}

    monkeypatch.setattr(
        get_usb_info_module.pyudev.Devices,
        "from_device_number",
        lambda *args, **kwargs: mock_device,
    )

    result = get_usb_info_module.get_usb_info(mount_path)
    assert result is None

Adjust the final assertions (assert result is None vs assert result == {} or similar) to match the actual "empty" value returned by get_usb_info in your codebase, keeping it consistent with the existing test_get_usb_info_returns_empty_when_pyudev_fails expectations.

Comment on lines +107 to 111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Add tests for PermissionError and malformed pyudev size to fully cover get_usb_info error handling.

This currently only exercises the generic Exception path via os.stat failure. To fully cover get_usb_info’s error handling, please also add tests that:

  1. Simulate PermissionError from os.stat or pyudev.Devices.from_device_number and assert get_usb_info returns None.
  2. Simulate a non-numeric device.attributes.get("size") that causes int(size_attr) to raise ValueError, and assert it also returns None.

These will ensure the dedicated PermissionError branch and malformed size handling remain covered and regressions are caught.

Expand All @@ -82,9 +104,15 @@ def test_get_usb_info_returns_empty_when_lsblk_fails(monkeypatch) -> None:
lambda *args, **kwargs: [SimpleNamespace(mountpoint=mount_path, device=device_node)],
)

def raise_lsblk_error(*args, **kwargs):
raise subprocess.CalledProcessError(returncode=1, cmd="lsblk")
# Mock os.stat safely
os_stat_orig = get_usb_info_module.os.stat

def mock_os_stat(p):
if str(p).startswith("/dev/"):
raise Exception("stat fail")
return os_stat_orig(p)

monkeypatch.setattr(get_usb_info_module.subprocess, "check_output", raise_lsblk_error)
monkeypatch.setattr(get_usb_info_module.os, "stat", mock_os_stat)

# This should return None because of the catch-all Exception in get_usb_info
assert get_usb_info_module.get_usb_info(mount_path) is None
Loading
Loading