Skip to content

Commit 9460f75

Browse files
authored
feat: switch from libusb to libusb-package-tng for Python 3.14+ support
- Replace `libusb>=1.0.27; sys_platform == 'win32'` with `libusb-package-tng>=1.0.29.0` in pyproject.toml (cross-platform, no per-Python-release rebuilds needed) - Update `init_libusb_windows()` in stm32_dfu.py to use the new `libusb_package_tng.find_library` API with OS fallback - Add `TestInitLibusbWindows` test class covering the new behaviour: find_library usage, caching, fallback, and error on missing backend Closes #101
1 parent 3a37f68 commit 9460f75

5 files changed

Lines changed: 102 additions & 72 deletions

File tree

=1.0.29.0

Whitespace-only changes.

mpflash/flash/stm32_dfu.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,21 +13,25 @@ def init_libusb_windows():
1313
"""
1414
Initializes the libusb backend on Windows and caches it module-wide.
1515
16+
Uses libusb_package_tng which embeds pre-built libusb binaries and supports
17+
Python 3.14+ without requiring per-release package updates.
18+
1619
Returns:
1720
The usb backend object if successful, None otherwise.
1821
"""
1922
global _libusb_backend
2023
if _libusb_backend is not None:
2124
return _libusb_backend
2225

23-
import libusb # type: ignore
26+
import libusb_package_tng # type: ignore
2427
import usb.backend.libusb1 as libusb1
2528

26-
arch = "x86_64" if platform.architecture()[0] == "64bit" else "x86"
27-
libusb1_dll = Path(libusb.__file__).parent / "_platform" / "windows" / arch / "libusb-1.0.dll"
28-
if not libusb1_dll.exists():
29-
raise FileNotFoundError(f"libusb1.dll not found at {libusb1_dll}")
30-
_libusb_backend = libusb1.get_backend(find_library=lambda x: libusb1_dll.as_posix())
29+
_libusb_backend = libusb1.get_backend(find_library=libusb_package_tng.find_library)
30+
if _libusb_backend is None:
31+
# Fall back to any OS-provided libusb
32+
_libusb_backend = libusb1.get_backend()
33+
if _libusb_backend is None:
34+
raise RuntimeError("Could not find a usable libusb backend")
3135
return _libusb_backend
3236

3337

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ dependencies = [
3737
"questionary>=2.0.0",
3838
"jsonlines>=4.0.0",
3939
"jsons>=1.6.3",
40-
"libusb>=1.0.27; sys_platform == 'win32'",
40+
"libusb-package-tng>=1.0.29.0",
4141
"pywin32>=310; sys_platform == 'win32'",
4242
"loguru>=0.7.2",
4343
"mpremote>=1.22.0",

tests/flash/test_stm32_dfu.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,91 @@ def test_windows_calls_libusb_init(self, mock_platform):
6262
mock_init.assert_called_once()
6363

6464

65+
# ---------------------------------------------------------------------------
66+
# init_libusb_windows
67+
# ---------------------------------------------------------------------------
68+
69+
70+
class TestInitLibusbWindows:
71+
def setup_method(self):
72+
"""Reset the cached backend before each test."""
73+
import mpflash.flash.stm32_dfu as mod
74+
75+
mod._libusb_backend = None
76+
77+
def test_uses_libusb_package_tng_find_library(self):
78+
"""init_libusb_windows passes libusb_package_tng.find_library to get_backend."""
79+
mock_backend = MagicMock()
80+
mock_tng = MagicMock()
81+
mock_tng.find_library = MagicMock()
82+
83+
with (
84+
patch.dict("sys.modules", {"libusb_package_tng": mock_tng}),
85+
patch("usb.backend.libusb1.get_backend", return_value=mock_backend) as mock_get,
86+
):
87+
from mpflash.flash import stm32_dfu
88+
89+
stm32_dfu._libusb_backend = None
90+
result = stm32_dfu.init_libusb_windows()
91+
92+
mock_get.assert_called_once_with(find_library=mock_tng.find_library)
93+
assert result is mock_backend
94+
95+
def test_caches_backend_on_second_call(self):
96+
"""init_libusb_windows returns cached result without re-importing on second call."""
97+
mock_backend = MagicMock()
98+
mock_tng = MagicMock()
99+
100+
with (
101+
patch.dict("sys.modules", {"libusb_package_tng": mock_tng}),
102+
patch("usb.backend.libusb1.get_backend", return_value=mock_backend) as mock_get,
103+
):
104+
from mpflash.flash import stm32_dfu
105+
106+
stm32_dfu._libusb_backend = None
107+
first = stm32_dfu.init_libusb_windows()
108+
second = stm32_dfu.init_libusb_windows()
109+
110+
assert first is second
111+
# get_backend should only be called once due to caching
112+
mock_get.assert_called_once()
113+
114+
def test_falls_back_to_os_backend_when_tng_returns_none(self):
115+
"""Falls back to OS-provided libusb when libusb_package_tng returns None."""
116+
mock_os_backend = MagicMock()
117+
mock_tng = MagicMock()
118+
119+
def fake_get_backend(find_library=None):
120+
if find_library is not None:
121+
return None # tng found nothing
122+
return mock_os_backend # OS fallback
123+
124+
with (
125+
patch.dict("sys.modules", {"libusb_package_tng": mock_tng}),
126+
patch("usb.backend.libusb1.get_backend", side_effect=fake_get_backend),
127+
):
128+
from mpflash.flash import stm32_dfu
129+
130+
stm32_dfu._libusb_backend = None
131+
result = stm32_dfu.init_libusb_windows()
132+
133+
assert result is mock_os_backend
134+
135+
def test_raises_runtime_error_when_no_backend_found(self):
136+
"""Raises RuntimeError when neither libusb_package_tng nor OS provides a backend."""
137+
mock_tng = MagicMock()
138+
139+
with (
140+
patch.dict("sys.modules", {"libusb_package_tng": mock_tng}),
141+
patch("usb.backend.libusb1.get_backend", return_value=None),
142+
):
143+
from mpflash.flash import stm32_dfu
144+
145+
stm32_dfu._libusb_backend = None
146+
with pytest.raises(RuntimeError, match="libusb backend"):
147+
stm32_dfu.init_libusb_windows()
148+
149+
65150
# ---------------------------------------------------------------------------
66151
# flash_stm32_dfu – guard conditions
67152
# ---------------------------------------------------------------------------

uv.lock

Lines changed: 6 additions & 65 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)