Skip to content

Support recent zigpy and Python 3.14 - #63

Merged
TheJulianJES merged 9 commits into
zigpy:devfrom
TheJulianJES:tjj/zigpy-compat
Aug 25, 2026
Merged

Support recent zigpy and Python 3.14#63
TheJulianJES merged 9 commits into
zigpy:devfrom
TheJulianJES:tjj/zigpy-compat

Conversation

@TheJulianJES

@TheJulianJES TheJulianJES commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Brings zigpy-cli up to date with recent zigpy (2.1.0) and the 3.11–3.14 Python matrix. zigpy radio was completely broken on Python 3.14, and uv sync failed outright, so this fixes both along with the packaging metadata that had drifted out of date.

Every zigpy radio ... command crashed on Python 3.14

click_coroutine called asyncio.get_event_loop() from a sync context. As of Python 3.14 that raises instead of creating a loop, so every async command died before doing anything:

File "zigpy_cli/cli.py", line 19, in inner
    loop = asyncio.get_event_loop()
RuntimeError: There is no current event loop in thread 'MainThread'.

The loop is now created explicitly. It has to be a single shared loop rather than a per-invocation asyncio.run(), because the work is split across three callbacks: the radio group callback constructs the ControllerApplication, the subcommand awaits connect() on it, and radio_cleanup (registered via ctx.call_on_close) later awaits shutdown(). connect() creates the transport and background tasks on whichever loop is running at the time, and shutdown() has to tear them down on that same loop.

asyncio.set_event_loop() is called on every lookup rather than only when the loop is created. The concrete reason is that asyncio.run() clears the thread's current-loop registration when it exits, so a create-only registration could be left stale for the rest of the process; re-registering is idempotent and self-heals. Radio libraries do call asyncio.get_event_loop() at runtime (bellows/uart.py, zigpy_deconz/api.py), though those particular calls happen inside a running loop, where the running loop takes precedence — so this is defensive rather than a fix for an observed crash.

zigpy.backups was used without being imported

radio restore calls zigpy.backups.NetworkBackup.from_dict(), but radio.py never imported zigpy.backups — it only resolved because zigpy.application happens to import it. Now imported directly.

zigpy.state, zigpy.zdo and zigpy.zdo.types were imported but unused, and are dropped. Ruff could not flag either problem: those imports only bind the name zigpy, which is what hid the missing import in the first place.

Packaging metadata

  • requires-python was >=3.8, which was already false. zigpy has required 3.11 since 0.85.0, and radio.py uses asyncio.TaskGroup (3.11+). It also made uv sync fail, since uv resolves across the whole declared range — the resolver names the >=3.8 range and zigpy's >=3.11 as the conflict. Now >=3.11.
  • Dependency floors were stale enough to resolve to broken packages. uv lock --resolution lowest-direct picked radio libraries predating the zigpy APIs in use, and unpinned scapy resolved to 2.3.1 — a Python 2-era release that fails to build (os.chmod(fname,0755)). Floors are now set to releases that work with recent zigpy on 3.11+; all five radio libraries were checked to import at their declared floors.
  • Test requirements moved to [dependency-groups] (testing + ci), matching zigpy, and requirements_test.txt is removed. The shared CI workflow tries uv sync --group ci first and only falls back to requirements_test.txt; the fallback also installed pre-commit, pytest-xdist and pytest-github-actions-annotate-failures itself, so those had to move into the groups or the pre-commit job and the -n auto pytest runs would break once the modern path is taken. The test floors were stale too — pytest 7.1.2 uses ast.Str, removed in Python 3.14, so resolving at the declared floors could not run on the 3.14 matrix entry at all. (CI itself was not broken by this, since the fallback installed pytest unpinned.)
  • uv.lock is gitignored rather than committed, matching zigpy.

Breaking changes

  • Python 3.8–3.10 are no longer supported. 3.8 already could not install at all — the previous zigpy>=0.75.0 floor itself required 3.9+. On 3.9/3.10 the CLI could only ever pair with zigpy ≤0.84, and radio packet-capture already required 3.11 (asyncio.TaskGroup).
  • The testing extra is gone. Dependency groups are not extras, so pip install zigpy-cli[testing] no longer installs test dependencies — use uv sync --group testing, or pip 25.1+ --group. Nothing in the repo or CI referenced the extra.
  • zigpy-zboss support is removed, below.

zigpy-zboss support removed

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 1.2.0 emits deprecation warnings against it. Dropped for now — happy to restore it once the cap upstream is relaxed.

zboss is no longer a valid radio argument, so it now fails on the click.Choice with the list of supported radios.

zigpy radio xbee needed zigpy-xbee 0.22.0

zigpy moved the quirks API out of the library (zigpy#1846), leaving zigpy.quirks as a shim that forwards to zhaquirks, and zigpy-xbee still subclassed zigpy.quirks.CustomDevice. Any install without zha-quirks present therefore failed at import:

ModuleNotFoundError: No module named 'zhaquirks'

zigpy/zigpy-xbee#179 fixed this and shipped in zigpy-xbee 0.22.0, so the floor here is set to that release. It also fixed a serialx incompatibility in zigpy-xbee's baudrate setter, which init_api_mode() reaches only once entering AT command mode at the configured baudrate has failed and it starts scanning the other known baudrates.

0.22.0 requires zigpy>=2.0.0, which makes 2.0.0 the real floor for the whole project, so that is now declared directly instead of a zigpy>=0.85.0 that no longer described anything installable.

Testing

  • Full suite and pre-commit run --all-files (black + ruff) pass on 3.14.
  • Four tests are added for the event-loop behaviour. The two that guard the actual fixes were checked against the corresponding pre-fix code rather than only against the new code: the original asyncio.get_event_loop() raises on 3.14, and the create-only registration fails the re-registration test.
  • Installed from a clean venv at both the declared floors (uv sync --resolution lowest-direct) and the latest resolvable set (zigpy 2.1.0); the suite passes on both.
  • zigpy radio znp <pty> info and zigpy radio xbee <pty> info exercised against a pty: group callback, subcommand and cleanup all run on the shared loop, and the failure propagates cleanly. Both reach the radio protocol layer.
  • OTA and pcap paths spot-checked against zigpy 2.1.0 — image parse/validate, index generation, firmware dump, and pcap output readable by scapy.

Not tested against real hardware — radio verification used a pty, which reaches the protocol layer but not a physical coordinator.

`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.
`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 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+.
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.
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.
`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.
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.
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 87.50000% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 7.61%. Comparing base (d05db0f) to head (1a8706a).

Files with missing lines Patch % Lines
zigpy_cli/radio.py 0.00% 1 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             dev     #63      +/-   ##
========================================
+ Coverage   2.61%   7.61%   +5.00%     
========================================
  Files          9       9              
  Lines        613     617       +4     
========================================
+ Hits          16      47      +31     
+ Misses       597     570      -27     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.

@puddly puddly left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks!! zigpy-cli has gotten a bit crusty 😅

@TheJulianJES

TheJulianJES commented Aug 25, 2026

Copy link
Copy Markdown
Contributor Author

@puddly Any opinion on v1.3.0 or v2.0.0 release for this? (zigpy-zboss is technically a breaking change but zigpy-cli doesn't seem to have followed semantic versioning very well so far, so I'm not sure it's worth the jump 😅).

I guess zigpy is already at 2.0.0, so maybe we can justify it to keep up with that as well. 😄

@puddly

puddly commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

It's a development tool either way, I think any version is good. 2.0.0 seems good. We can throw in zigpy-ziggurat in 2.1.0 or something.

@TheJulianJES
TheJulianJES marked this pull request as ready for review August 25, 2026 19:47

@zigpy-review-bot zigpy-review-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reviewed the full branch at d9328c2 in a clean worktree, on Python 3.11 and 3.14, at both the declared floors and the latest resolvable set. Approving — the changes are correct and, unusually, the description matches what the code actually does on every load-bearing claim I checked. Everything below is non-blocking.

What I verified

The 3.14 event-loop fix is correct, and the shared loop is genuinely required.
Confirmed on 3.14.5 that asyncio.get_event_loop() raises RuntimeError: There is no current event loop in thread 'MainThread' when no loop is set, so the old click_coroutine did break every zigpy radio ... command.
I then instrumented the three callbacks with a stub ControllerApplication driven through click's CliRunner: the group callback's __init__, the subcommand's connect()/load_network_info(), and radio_cleanup's shutdown() all report the same loop id — and shutdown() still runs when the subcommand raises, since click fires ctx.call_on_close during context teardown either way. So the single shared loop is load-bearing rather than incidental, and a per-invocation asyncio.run() really would have broken cleanup.
Also confirmed under -W error::DeprecationWarning that asyncio.set_event_loop() is not deprecated on 3.14, so re-registering on every lookup costs nothing.
End-to-end against a pty: zigpy -vv radio znp <pty> info reaches zigpy_znp.api._skip_bootloader and fails with a clean TimeoutError — group callback, subcommand and teardown all executed on the shared loop.

The missing-import class of bug is now fully gone, not just fixed in one spot.
Rather than spot-checking, I ran an AST pass over every module in zigpy_cli/, comparing each zigpy.<submodule> attribute chain against what that module actually imports. Only radio.py (zigpy.backups, zigpy.types) and database.py (zigpy.appdb) reach through the bare zigpy name, and after this PR both are directly imported — no other latent dependency on zigpy.application's import side effects remains. Dropping zigpy.state, zigpy.zdo and zigpy.zdo.types is safe; nothing in the package references them.

The floors are honest.
uv sync --group ci --resolution lowest-direct resolves and installs on both 3.11 and 3.14 (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, scapy 2.5.0, click 8.1.0), and at those floors all five <radio>.zigbee.application modules plus every zigpy_cli module import cleanly on both versions — including scapy.all at 2.5.0 on 3.14, which was the one I expected to break. The suite passes at the floors on 3.11 and 3.14, and at latest on 3.14.
Every zigpy symbol the package touches also resolves at the 2.0.0 floor, not just at 2.1.0: appdb.DB_VERSION, backups.NetworkBackup.from_dict, types.Channels, ota.image.{ElementTagId,HueSBLOTAImage,parse_ota_image}, ota.validators.validate_ota_image, types.named._hex_string_to_bytes.

The zboss removal is justified by current upstream state, not just as of when you wrote it.
zigpy-zboss's latest release (2.0.5) still declares zigpy<2,>=0.92.0, so it does cap the whole install below zigpy 2.x — the reasoning holds today. No dangling references either: RADIO_TO_PYPI is derived from RADIO_TO_PACKAGE, RADIO_LOGGING_CONFIGS is updated in step, and the README never mentioned zboss.

The packaging move matches zigpy, and CI is demonstrably taking the modern path.
The shared workflow's if ! uv sync --group ci fallback is what previously installed pre-commit, pytest-xdist and pytest-github-actions-annotate-failures, so folding those into the groups was required rather than cosmetic. All four Prepare base dependencies jobs are green here, which means uv sync --group ci succeeded and the fallback never ran — worth noting that the fallback is now a dead end (no requirements_test.txt), so if that path is ever taken again it will fail loudly rather than recover. zigpy is in exactly the same position, so this is the org convention, not a regression.

The added tests are the right ones. I checked that test_get_or_create_event_loop_reregisters_cleared_loop fails against create-only registration, and that test_click_coroutine_without_running_loop fails against the old asyncio.get_event_loop() on 3.14 — both genuinely guard their fix rather than only exercising the new code.

A second-opinion static pass with another model over the same diff returned no findings, independently reaching the same conclusions on the loop refactor, the removed imports, the floors and the zboss removal.

Two optional notes

asyncio_mode is the one thing not carried over from zigpy's pyproject — inline on the pytest-asyncio line. No correctness risk, purely ergonomic.

Loop teardown is now explicitly owned by this code, which turns async-generator finalization into a visible loose end. _LOOP is created here but never closed, and loop.shutdown_asyncgens() is never called. The old get_event_loop() path did exactly the same, so this is not a regression and nothing in this PR needs to change — but radio packet-capture consumes app.packet_capture(...) as an async generator inside a TaskGroup, so on Ctrl-C that generator's finally may never run. Now that the loop's lifetime is explicit and in one place, that is a cheap follow-up if it ever misbehaves in practice.

On the version question already settled in the comments: 2.0.0 looks right to me too — dropping 3.8–3.10, removing the testing extra, and removing a radio are three separate user-visible breaks landing in one release.

Comment thread pyproject.toml
"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-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.
@TheJulianJES
TheJulianJES merged commit d5de026 into zigpy:dev Aug 25, 2026
12 checks passed
Comment thread .gitignore
Comment on lines +81 to +82
# uv
uv.lock

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants