Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,6 @@ ENV/

TI Z-Stack/

zigpy-znp-*.*.*/
zigpy-znp-*.*.*/
# uv
uv.lock
Comment on lines +81 to +82

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

note: uv.lock is added to .gitignore for now, but we may want to track that in the future. We can look at that in a future PR.

44 changes: 27 additions & 17 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional, for consistency with zigpy: zigpy's own pyproject.toml pairs pytest-asyncio with

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"

and this PR adds the dependency without either. There is no correctness risk in leaving it out — I checked that both pytest 8.4.0 (the declared floor) and 9.1.1 fail an unmarked async def test rather than silently skipping it, so nothing can pass unnoticed. It just means the first async test added here will need an explicit @pytest.mark.asyncio, and setting the fixture loop scope now avoids a behaviour change later. Entirely reasonable to skip if you would rather keep the diff focused.

@TheJulianJES TheJulianJES Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Doesn't matter now but I think we can add it for consistency. Did so with: 1a8706a (though the second part of the commit message with function being the default is not correct – doesn't really matter as we're squash-merging and I edit out the commit body anyway (or have Refined GitHub on to auto do that)).

"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
Expand Down
5 changes: 0 additions & 5 deletions requirements_test.txt

This file was deleted.

75 changes: 75 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 24 additions & 1 deletion zigpy_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 0 additions & 11 deletions zigpy_cli/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"ezsp": "bellows",
"deconz": "zigpy_deconz",
"xbee": "zigpy_xbee",
"zboss": "zigpy_zboss",
"zigate": "zigpy_zigate",
"znp": "zigpy_znp",
}
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 1 addition & 3 deletions zigpy_cli/radio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading