Fix Python 3.14 compatibility: replace deprecated asyncio.get_event_loop()#2831
Fix Python 3.14 compatibility: replace deprecated asyncio.get_event_loop()#2831Jscoats wants to merge 2 commits into
Conversation
…oop() Replace all usage of asyncio.get_event_loop() which raises RuntimeError in Python 3.14 when no event loop exists in the current thread. Changes: - CLI entry points: get_event_loop() + run_until_complete() -> asyncio.run() - Core library: get_event_loop() -> get_running_loop() in async contexts and __init__ methods called within running event loops - Remove deprecated loop= kwargs from StreamReader, ensure_future - Fix asyncio.wait() to accept set instead of list (knock.py, atvscript.py) - Modernize all examples to use asyncio.run() - Add regression test verifying CLI scripts bootstrap without pre-existing event loop - Update test conftest to match new appstart() signature Public API (scan, connect, pair) signatures are preserved. Fixes postlund#2829 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Plz merge. This issue makes it impossible to set up pyatv on 3.14. |
|
I was able to get |
| """Find a device and print what is playing.""" | ||
| print(f"Discovering {device['name']} on network...") | ||
| confs = await pyatv.scan(loop, identifier=device["identifiers"]) | ||
| confs = await pyatv.scan(identifier=device["identifiers"]) |
There was a problem hiding this comment.
This can't work, pyatv.scan requires a loop as first argument.
|
|
||
| print(f"Connecting to {conf.address}") | ||
| atv = await pyatv.connect(conf, loop) | ||
| atv = await pyatv.connect(conf) |
There was a problem hiding this comment.
Same here, loop must be passed.
| ) | ||
|
|
||
| print(f"Connecting to {config.address}") | ||
| atv = await connect(config, loop) |
| async def pair_with_device(loop): | ||
| async def pair_with_device(): | ||
| """Make it possible to pair with device.""" | ||
| atvs = await scan(loop, timeout=5, protocol=Protocol.AirPlay) |
| return | ||
|
|
||
| pairing = await pair(atvs[0], Protocol.MRP, loop) | ||
| pairing = await pair(atvs[0], Protocol.MRP) |
| async def play_url(device_id: str, airplay_credentials: str, url: str): | ||
| """Connect to an Apple TV and stream file via AirPlay.""" | ||
| print("* Discovering device on network...") | ||
| atvs = await pyatv.scan(loop, identifier=device_id) |
|
|
||
| conf = atvs[0] | ||
| conf.set_credentials(Protocol.AirPlay, airplay_credentials) | ||
| atv = await pyatv.connect(conf, loop) |
| async def print_what_is_playing(): | ||
| """Find a device and print what is playing.""" | ||
| print("Discovering devices on network...") | ||
| atvs = await pyatv.scan(loop, timeout=5) |
| return | ||
|
|
||
| print(f"Connecting to {atvs[0].address}") | ||
| atv = await pyatv.connect(atvs[0], loop) |
| storage = FileStorage.default_storage(asyncio.get_running_loop()) | ||
| await storage.load() | ||
|
|
||
| atvs = await scan(loop, timeout=5, hosts=[host], storage=storage) |
There was a problem hiding this comment.
Why is the loop needed to be passed around? Instead, whenever a task needs the loop, they can run asyncio.get_running_loop()
There was a problem hiding this comment.
I guess that means changing the public API
There was a problem hiding this comment.
Yes, it is because that's how the Api was defined a long time ago (when passing the loop around was a thing). I might define additional functions to deal with this later.
| async def stream_with_push_updates(address: str, filename: str): | ||
| """Find a device and print what is playing.""" | ||
| print("* Discovering device on network...") | ||
| atvs = await pyatv.scan(loop, hosts=[address], timeout=5) |
| conf = atvs[0] | ||
|
|
||
| print("* Connecting to", conf.address) | ||
| atv = await pyatv.connect(conf, loop) |
| if device_id in request.app["atv"]: | ||
| return web.Response(text=f"Already connected to {device_id}") | ||
|
|
||
| results = await pyatv.scan(identifier=device_id, loop=loop) |
| add_credentials(results[0], request.query) | ||
|
|
||
| try: | ||
| atv = await pyatv.connect(results[0], loop=loop) |
| address: IPv4Address, | ||
| ports: List[int], | ||
| loop: asyncio.AbstractEventLoop, | ||
| loop: asyncio.AbstractEventLoop = None, |
There was a problem hiding this comment.
This typing is incorrect, must add | None to make the argument optional.
|
A few comments, can merge after those are fixed. |
For end-users who want to install |
|
@postlund your comments are addressed in Jscoats@743e0df. if you cherry-pick that one, we can merge this. |
Summary
Fixes #2829 — All
pyatvCLI scripts (atvremote,atvscript,atvlog,atvproxy) crash on Python 3.14 due toasyncio.get_event_loop()raisingRuntimeErrorwhen no event loop exists in the current thread.Changes
CLI entry points (4 scripts):
main():get_event_loop()+run_until_complete()→asyncio.run()loopparameter threading, each now callsasyncio.get_running_loop()where neededCore library (10 files):
asyncio.get_event_loop()→asyncio.get_running_loop()(in__init__methods and async functions)Additional deprecation fixes:
asyncio.StreamReader(loop=loop)→asyncio.StreamReader()(param removed in 3.10)asyncio.ensure_future(..., loop=loop)→asyncio.ensure_future(...)(param removed in 3.10)asyncio.wait([list])→asyncio.wait(set(...))inknock.py(list deprecated in 3.11)Examples (9 files):
asyncio.run()instead ofget_event_loop().run_until_complete()Tests:
tests/scripts/conftest.pyto callappstart()withoutlooptest_asyncio_compat.py— regression tests verifying all 4 CLI scripts bootstrap without a pre-existing event loopWhat's NOT changed
pyatv.scan(),pyatv.connect(),pyatv.pair()) — all signatures preservedasyncio.get_running_loop()is available since Python 3.7, so this is safe across all supported versions (3.9+)Note on
asyncio.run()vsloop.run_until_complete()asyncio.run()additionally finalizes async generators and shuts down the default executor on exit, which is slightly better cleanup behavior. This has no observable effect on CLI usage.Testing
grep -rn "get_event_loop" pyatv/ examples/returns zero matches