From 23b483f288aa4429b58536b389de2654c560bbf6 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 15:39:48 +0200 Subject: [PATCH 1/9] Fix async commands crashing on Python 3.14 `asyncio.get_event_loop()` no longer creates an event loop when none is set: as of Python 3.14 it raises `RuntimeError` instead. Every `click_coroutine` callback ran through it, so all `zigpy radio ...` commands failed immediately with "There is no current event loop in thread 'MainThread'". Create the loop explicitly instead. It has to be a single shared loop rather than a per-invocation `asyncio.run()`: the `radio` group callback constructs the `ControllerApplication`, the subcommand then uses it and `radio_cleanup` shuts it down, and those objects are bound to the loop they were created on. --- tests/test_cli.py | 62 +++++++++++++++++++++++++++++++++++++++++++++++ zigpy_cli/cli.py | 21 +++++++++++++++- 2 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 tests/test_cli.py diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..7405bf5 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,62 @@ +import asyncio + +import pytest + +from zigpy_cli import cli as cli_module +from zigpy_cli.cli import click_coroutine, get_or_create_event_loop + + +@pytest.fixture(autouse=True) +def reset_loop(): + """Keep the module-level loop from leaking between tests.""" + old_loop = cli_module._LOOP + cli_module._LOOP = None + + yield + + if cli_module._LOOP is not None and not cli_module._LOOP.is_closed(): + cli_module._LOOP.close() + + cli_module._LOOP = old_loop + asyncio.set_event_loop(None) + + +def test_click_coroutine_without_running_loop(): + """`click_coroutine` works when no event loop exists yet. + + Python 3.14's `asyncio.get_event_loop()` raises instead of creating one. + """ + asyncio.set_event_loop(None) + + @click_coroutine + async def cmd(value): + await asyncio.sleep(0) + return value * 2 + + assert cmd(21) == 42 + + +def test_click_coroutine_reuses_the_same_loop(): + """All callbacks must share a loop: the group creates the app, the + subcommand uses it, and the cleanup callback shuts it down.""" + loops = [] + + @click_coroutine + async def cmd(): + loops.append(asyncio.get_running_loop()) + + cmd() + cmd() + + assert loops[0] is loops[1] + assert loops[0] is get_or_create_event_loop() + + +def test_get_or_create_event_loop_replaces_closed_loop(): + loop = get_or_create_event_loop() + loop.close() + + new_loop = get_or_create_event_loop() + + assert new_loop is not loop + assert not new_loop.is_closed() diff --git a/zigpy_cli/cli.py b/zigpy_cli/cli.py index 694f647..c3f9b24 100644 --- a/zigpy_cli/cli.py +++ b/zigpy_cli/cli.py @@ -13,10 +13,29 @@ ROOT_LOGGER = logging.getLogger() +_LOOP: asyncio.AbstractEventLoop | None = None + + +def get_or_create_event_loop() -> asyncio.AbstractEventLoop: + """Return the shared event loop, creating it on first use. + + Every coroutine callback has to run on the *same* loop: the `radio` group + callback creates the `ControllerApplication`, the subcommand then uses it, and + `radio_cleanup` shuts it down when the context closes. + """ + global _LOOP + + if _LOOP is None or _LOOP.is_closed(): + _LOOP = asyncio.new_event_loop() + asyncio.set_event_loop(_LOOP) + + return _LOOP + + def click_coroutine(cmd): @functools.wraps(cmd) def inner(*args, **kwargs): - loop = asyncio.get_event_loop() + loop = get_or_create_event_loop() return loop.run_until_complete(cmd(*args, **kwargs)) return inner From d717a93c139d8a19a0aaea49edd6f67bfbc381b9 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 15:39:48 +0200 Subject: [PATCH 2/9] Import `zigpy.backups` explicitly in `radio.py` `radio restore` uses `zigpy.backups.NetworkBackup`, but the module was never imported: the attribute only resolved because `zigpy.application` happens to import it, which is not something to rely on. Import it directly and drop `zigpy.state`/`zigpy.zdo`, which are unused. Ruff could not flag those as unused imports since they only bind the name `zigpy`, which is what hid the missing import in the first place. --- zigpy_cli/radio.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/zigpy_cli/radio.py b/zigpy_cli/radio.py index 7cd9177..b55e0fc 100644 --- a/zigpy_cli/radio.py +++ b/zigpy_cli/radio.py @@ -11,10 +11,8 @@ import sys import click -import zigpy.state +import zigpy.backups import zigpy.types -import zigpy.zdo -import zigpy.zdo.types from zigpy.application import ControllerApplication from zigpy_cli.cli import cli, click_coroutine From 24687e5a0c4cc13b5505eae3c193cbc92ec7c85e Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 15:39:48 +0200 Subject: [PATCH 3/9] Require Python 3.11+ and raise dependency floors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit zigpy has required Python 3.11 since 0.85.0, so `requires-python = ">=3.8"` was already unsatisfiable — it also made `uv sync` fail outright, since uv resolves for the whole declared Python range. The dependency floors had drifted just as far: resolving them (`uv lock --resolution lowest-direct`) picked up radio libraries predating the current zigpy APIs, and unpinned `scapy` resolved back to 2.3.1, a Python 2-era release that fails to build at all. Pin each one to a release that works with recent zigpy and supports Python 3.11+. --- pyproject.toml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 5edb241..0c72144 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,18 +12,18 @@ authors = [ ] readme = "README.md" license = {text = "GPL-3.0"} -requires-python = ">=3.8" +requires-python = ">=3.11" dependencies = [ - "click", - "coloredlogs", - "scapy", - "zigpy>=0.75.0", - "bellows>=0.43.0", - "zigpy-deconz>=0.21.0", - "zigpy-xbee>=0.18.0", - "zigpy-zboss>=1.1.0", - "zigpy-zigate>=0.11.0", - "zigpy-znp>=0.11.1" + "click>=8.1", + "coloredlogs>=15.0", + "scapy>=2.5.0", + "zigpy>=0.85.0", + "bellows>=0.47.0", + "zigpy-deconz>=0.25.0", + "zigpy-xbee>=0.21.0", + "zigpy-zboss>=1.2.0", + "zigpy-zigate>=0.14.0", + "zigpy-znp>=1.0.0" ] [tool.setuptools.packages.find] From bef69fa7c57d0a8a3d4065ea222951ad379ccb28 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 15:39:57 +0200 Subject: [PATCH 4/9] Use uv dependency groups for test requirements The shared zigpy CI workflow prefers `uv sync --group ci` and only falls back to `requirements_test.txt` when that fails, so declare the groups the way zigpy itself does and drop the fallback file. The `testing` group gains `pre-commit` and `coverage`, and `ci` adds `pytest-xdist` and `pytest-github-actions-annotate-failures`. The fallback path installed all of these itself, so without them the pre-commit and coverage CI jobs would break once the modern path starts being used. Every entry gets a floor, for the same reason the runtime dependencies did: unpinned, `coverage` and `pytest-xdist` resolve to releases too old to import, and `pytest` 7.1.2 uses `ast.Str`, removed in Python 3.12, so the previous floors could not run on the 3.13/3.14 matrix entries at all. `uv.lock` is ignored rather than committed, matching zigpy. --- .gitignore | 4 +++- pyproject.toml | 19 +++++++++++++------ requirements_test.txt | 5 ----- 3 files changed, 16 insertions(+), 12 deletions(-) delete mode 100644 requirements_test.txt diff --git a/.gitignore b/.gitignore index 523a8cb..a2a8553 100644 --- a/.gitignore +++ b/.gitignore @@ -77,4 +77,6 @@ ENV/ TI Z-Stack/ -zigpy-znp-*.*.*/ \ No newline at end of file +zigpy-znp-*.*.*/ +# uv +uv.lock diff --git a/pyproject.toml b/pyproject.toml index 0c72144..89c6123 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,13 +29,20 @@ dependencies = [ [tool.setuptools.packages.find] exclude = ["tests", "tests.*"] -[project.optional-dependencies] +[dependency-groups] testing = [ - "pytest>=7.1.2", - "pytest-asyncio>=0.19.0", - "pytest-timeout>=2.1.0", - "pytest-mock>=3.8.2", - "pytest-cov>=3.0.0", + "coverage[toml]>=7.0", + "pre-commit>=3.5", + "pytest>=8.4", + "pytest-asyncio>=1.0", + "pytest-cov>=5.0", + "pytest-mock>=3.14", + "pytest-timeout>=2.3", +] +ci = [ + {include-group = "testing"}, + "pytest-github-actions-annotate-failures>=0.2", + "pytest-xdist>=3.5", ] [tool.setuptools-git-versioning] diff --git a/requirements_test.txt b/requirements_test.txt deleted file mode 100644 index 2913163..0000000 --- a/requirements_test.txt +++ /dev/null @@ -1,5 +0,0 @@ -coverage[toml] -pytest -pytest-asyncio -pytest-cov -pytest-timeout From 978ddb6e342188c1f7f0b1078da8a30d584ea569 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 17:53:48 +0200 Subject: [PATCH 5/9] Remove zigpy-zboss support zigpy-zboss 2.x pins `zigpy<2`, so depending on it held the whole install back: the resolver had to fall to zboss 1.2.0 (which declares an unbounded `zigpy>=0.60.2`) to allow a recent zigpy, and zboss 1.2.0 emits deprecation warnings against it. Drop the radio until upstream relaxes the cap. `zboss` is no longer a valid `radio` argument, so it now fails on the `click.Choice` with the list of supported radios rather than at import time. --- pyproject.toml | 1 - zigpy_cli/const.py | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 89c6123..27477bc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,6 @@ dependencies = [ "bellows>=0.47.0", "zigpy-deconz>=0.25.0", "zigpy-xbee>=0.21.0", - "zigpy-zboss>=1.2.0", "zigpy-zigate>=0.14.0", "zigpy-znp>=1.0.0" ] diff --git a/zigpy_cli/const.py b/zigpy_cli/const.py index 9766b93..1042fc6 100644 --- a/zigpy_cli/const.py +++ b/zigpy_cli/const.py @@ -11,7 +11,6 @@ "ezsp": "bellows", "deconz": "zigpy_deconz", "xbee": "zigpy_xbee", - "zboss": "zigpy_zboss", "zigate": "zigpy_zigate", "znp": "zigpy_znp", } @@ -48,16 +47,6 @@ "zigpy_xbee.api": logging.DEBUG, }, ], - "zboss": [ - { - "zigpy_zboss.zigbee.application": logging.INFO, - "zigpy_zboss.api": logging.INFO, - }, - { - "zigpy_zboss.zigbee.application": logging.DEBUG, - "zigpy_zboss.api": logging.DEBUG, - }, - ], "zigate": [ { "zigpy_zigate": logging.INFO, From 40638df1efe5a790973ad892721648577598531f Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 18:05:39 +0200 Subject: [PATCH 6/9] Keep the shared loop registered as the current event loop `asyncio.set_event_loop()` was only called on the branch that creates the loop, so the registration could go stale if anything cleared the thread's current loop in between commands. Nothing in the CLI does that today, but bellows (`uart.py`) and zigpy-deconz (`api.py`) both call `asyncio.get_event_loop()` at runtime, and on Python 3.14 that raises when no loop is set. Re-register unconditionally instead; it is idempotent when already current. --- tests/test_cli.py | 13 +++++++++++++ zigpy_cli/cli.py | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7405bf5..e1ae2c2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -60,3 +60,16 @@ def test_get_or_create_event_loop_replaces_closed_loop(): assert new_loop is not loop assert not new_loop.is_closed() + + +def test_get_or_create_event_loop_reregisters_cleared_loop(): + """The loop stays registered as current even if something else clears it. + + Radio libraries call `asyncio.get_event_loop()` at runtime, which fails on + Python 3.14 when no loop is set. + """ + loop = get_or_create_event_loop() + asyncio.set_event_loop(None) + + assert get_or_create_event_loop() is loop + assert asyncio.get_event_loop() is loop diff --git a/zigpy_cli/cli.py b/zigpy_cli/cli.py index c3f9b24..a1ec735 100644 --- a/zigpy_cli/cli.py +++ b/zigpy_cli/cli.py @@ -27,7 +27,11 @@ def get_or_create_event_loop() -> asyncio.AbstractEventLoop: if _LOOP is None or _LOOP.is_closed(): _LOOP = asyncio.new_event_loop() - asyncio.set_event_loop(_LOOP) + + # Re-register every time: radio libraries call `asyncio.get_event_loop()` at + # runtime, so the loop has to stay the thread's current one even if something + # else cleared it in the meantime. + asyncio.set_event_loop(_LOOP) return _LOOP From 8a3e22f25d69f8c62e3c3263c590adeb0d6192b0 Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 18:06:08 +0200 Subject: [PATCH 7/9] Keep the loop fixture's saved state self-consistent Teardown restored `_LOOP` to whatever it was before the test but cleared the thread's current loop unconditionally, so the two could disagree: `_LOOP` pointing at one loop while `asyncio.get_event_loop()` saw another. `_LOOP` is always `None` outside the tests, so nothing was actually wrong, but the save/restore bought nothing and would mislead once async tests exist. Reset both to `None` at each end instead, and clear the current loop on setup too so a test never inherits one from elsewhere. --- tests/test_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index e1ae2c2..c1e9f32 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -9,15 +9,15 @@ @pytest.fixture(autouse=True) def reset_loop(): """Keep the module-level loop from leaking between tests.""" - old_loop = cli_module._LOOP cli_module._LOOP = None + asyncio.set_event_loop(None) yield if cli_module._LOOP is not None and not cli_module._LOOP.is_closed(): cli_module._LOOP.close() - cli_module._LOOP = old_loop + cli_module._LOOP = None asyncio.set_event_loop(None) From d9328c24ef6605fd5c0c5855161c016c36f8044d Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 21:00:59 +0200 Subject: [PATCH 8/9] Require zigpy-xbee 0.22.0 for the zigpy 2.x fixes zigpy-xbee 0.22.0 ships zigpy/zigpy-xbee#179, which migrates off the quirks API zigpy moved out to zha-device-handlers. Before it, `zigpy radio xbee` failed at import with `ModuleNotFoundError: No module named 'zhaquirks'` on any install without zha-quirks present. 0.22.0 requires `zigpy>=2.0.0`, which becomes the real floor for the whole project, so declare that directly rather than leaving a `zigpy>=0.85.0` that no longer describes anything installable. --- pyproject.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 27477bc..e262c2f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,10 +17,10 @@ dependencies = [ "click>=8.1", "coloredlogs>=15.0", "scapy>=2.5.0", - "zigpy>=0.85.0", + "zigpy>=2.0.0", "bellows>=0.47.0", "zigpy-deconz>=0.25.0", - "zigpy-xbee>=0.21.0", + "zigpy-xbee>=0.22.0", "zigpy-zigate>=0.14.0", "zigpy-znp>=1.0.0" ] From 1a8706a6fc3fd32681b49a9a822c1d1c087084af Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Tue, 25 Aug 2026 22:47:41 +0200 Subject: [PATCH 9/9] Configure pytest-asyncio to match zigpy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pytest-asyncio` was already a test dependency but had no configuration, which leaves `asyncio_mode` at `strict`. In strict mode a plain `@pytest.fixture` async fixture is not handled at all — pytest errors out with "requested an async fixture ... with no plugin or hook that handled it" even when the test itself carries `@pytest.mark.asyncio`. `auto` makes async tests and fixtures work without markers, as they do in zigpy. `asyncio_default_fixture_loop_scope` silences no warning on pytest-asyncio 1.x, where `function` is already the default, but pinning it keeps the fixture and its test on one loop if that default ever changes. --- pyproject.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index e262c2f..ce03234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,10 @@ ci = [ "pytest-xdist>=3.5", ] +[tool.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" + [tool.setuptools-git-versioning] enabled = true