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 5edb241..ce03234 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,31 +12,41 @@ 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>=2.0.0", + "bellows>=0.47.0", + "zigpy-deconz>=0.25.0", + "zigpy-xbee>=0.22.0", + "zigpy-zigate>=0.14.0", + "zigpy-znp>=1.0.0" ] [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.pytest.ini_options] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "function" [tool.setuptools-git-versioning] enabled = true 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 diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..c1e9f32 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,75 @@ +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.""" + 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 = None + 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() + + +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 694f647..a1ec735 100644 --- a/zigpy_cli/cli.py +++ b/zigpy_cli/cli.py @@ -13,10 +13,33 @@ 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() + + # 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 + + 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 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, 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