Skip to content

Fix Python 3.14 compatibility: replace deprecated asyncio.get_event_loop()#2831

Open
Jscoats wants to merge 2 commits into
postlund:masterfrom
Jscoats:fix/python-3.14-asyncio-compat
Open

Fix Python 3.14 compatibility: replace deprecated asyncio.get_event_loop()#2831
Jscoats wants to merge 2 commits into
postlund:masterfrom
Jscoats:fix/python-3.14-asyncio-compat

Conversation

@Jscoats

@Jscoats Jscoats commented Mar 6, 2026

Copy link
Copy Markdown

Summary

Fixes #2829 — All pyatv CLI scripts (atvremote, atvscript, atvlog, atvproxy) crash on Python 3.14 due to asyncio.get_event_loop() raising RuntimeError when no event loop exists in the current thread.

Changes

CLI entry points (4 scripts):

  • main(): get_event_loop() + run_until_complete()asyncio.run()
  • Internal functions: removed loop parameter threading, each now calls asyncio.get_running_loop() where needed

Core library (10 files):

  • All 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(...)) in knock.py (list deprecated in 3.11)

Examples (9 files):

  • Modernized all examples to use asyncio.run() instead of get_event_loop().run_until_complete()

Tests:

  • Updated tests/scripts/conftest.py to call appstart() without loop
  • Added test_asyncio_compat.py — regression tests verifying all 4 CLI scripts bootstrap without a pre-existing event loop

What's NOT changed

  • Public API (pyatv.scan(), pyatv.connect(), pyatv.pair()) — all signatures preserved
  • asyncio.get_running_loop() is available since Python 3.7, so this is safe across all supported versions (3.9+)

Note on asyncio.run() vs loop.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

  • All 1248 existing tests pass (0 failures, 6 skipped) + 4 new regression tests
  • flake8 clean on all changed files
  • grep -rn "get_event_loop" pyatv/ examples/ returns zero matches
  • Live tested against Apple TV 4K (tvOS 26.3): scan, connect, play, pause all working

…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>
@alexchandel

Copy link
Copy Markdown

Plz merge. This issue makes it impossible to set up pyatv on 3.14.

@chid

chid commented May 15, 2026

Copy link
Copy Markdown

I was able to get atvremote working with a relatively simple change, same idea but less to refactor

      1019
      1020   def main():
      1021       """Application start here."""
      1022 -     loop = asyncio.get_event_loop()
      1022 +     try:
      1023 +         loop = asyncio.get_event_loop()
      1024 +     except RuntimeError:
      1025 +         loop = asyncio.new_event_loop()
      1026 +         asyncio.set_event_loop(loop)
      1027       return loop.run_until_complete(appstart(loop))
      1028
      1029

"""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"])

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Same here, loop must be passed.

)

print(f"Connecting to {config.address}")
atv = await connect(config, loop)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread examples/pairing.py
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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread examples/pairing.py
return

pairing = await pair(atvs[0], Protocol.MRP, loop)
pairing = await pair(atvs[0], Protocol.MRP)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread examples/play_url.py
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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread examples/play_url.py

conf = atvs[0]
conf.set_credentials(Protocol.AirPlay, airplay_credentials)
atv = await pyatv.connect(conf, loop)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

return

print(f"Connecting to {atvs[0].address}")
atv = await pyatv.connect(atvs[0], loop)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread examples/storage.py
storage = FileStorage.default_storage(asyncio.get_running_loop())
await storage.load()

atvs = await scan(loop, timeout=5, hosts=[host], storage=storage)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Why is the loop needed to be passed around? Instead, whenever a task needs the loop, they can run asyncio.get_running_loop()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I guess that means changing the public API

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

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.

Comment thread examples/stream.py
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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread examples/stream.py
conf = atvs[0]

print("* Connecting to", conf.address)
atv = await pyatv.connect(conf, loop)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread examples/tutorial.py
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)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread examples/tutorial.py
add_credentials(results[0], request.query)

try:
atv = await pyatv.connect(results[0], loop=loop)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

loop needed

Comment thread pyatv/support/knock.py
address: IPv4Address,
ports: List[int],
loop: asyncio.AbstractEventLoop,
loop: asyncio.AbstractEventLoop = None,

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

This typing is incorrect, must add | None to make the argument optional.

@postlund

Copy link
Copy Markdown
Owner

A few comments, can merge after those are fixed.

@Windows81

Copy link
Copy Markdown

For end-users who want to install

pip install git+https://github.com/Jscoats/pyatv.git@fix/python-3.14-asyncio-compat

@balloob

balloob commented Jun 8, 2026

Copy link
Copy Markdown

@postlund your comments are addressed in Jscoats@743e0df. if you cherry-pick that one, we can merge this.

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.

Docker image for latest/0.17 fails to run any pyatv scripts

6 participants