From 2964a7bdad99c300b5cb5fb8cf14862371d5b879 Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 08:57:24 +0100 Subject: [PATCH 1/4] Require Python 3.11 Python 3.10 reached its end of life in October 2026. asyncio.timeout replaces the async_timeout backport. --- .github/workflows/ci.yaml | 2 +- benchmarks/run.py | 4 +--- pyhap/camera.py | 10 ++-------- pyhap/camera_talkback.py | 5 +---- pyhap/hap_handler.py | 8 +------- pyhap/hds_server.py | 2 +- pyhap/util.py | 10 ++-------- pyproject.toml | 8 +++----- tests/test_hap_protocol.py | 2 +- tox.ini | 6 +----- 10 files changed, 14 insertions(+), 43 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ef2a7b5..11e0b290 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ jobs: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v4 diff --git a/benchmarks/run.py b/benchmarks/run.py index 8ffc0053..be72664a 100644 --- a/benchmarks/run.py +++ b/benchmarks/run.py @@ -754,9 +754,7 @@ def build_meta(args: argparse.Namespace) -> dict[str, Any]: """Metadata describing the code and machine the run was made on.""" return { "benchmark_version": 1, - "timestamp": datetime_module.datetime.now( - datetime_module.timezone.utc - ).isoformat(), + "timestamp": datetime_module.datetime.now(datetime_module.UTC).isoformat(), "commit": git_commit(), "label": args.label, "python": sys.version.split()[0], diff --git a/pyhap/camera.py b/pyhap/camera.py index 4caccd4e..f811f438 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -24,7 +24,6 @@ import logging import os import struct -import sys from typing import Any from uuid import UUID @@ -36,11 +35,6 @@ from pyhap.service import Service from pyhap.util import base64_to_bytes, byte_bool, callback, to_base64_str -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout - SETUP_TYPES = { "SESSION_ID": b"\x01", "STATUS": b"\x02", @@ -1598,10 +1592,10 @@ async def stop_stream(self, session_info: dict[str, Any]) -> None: logger.info("[%s] Stopping stream.", session_id) try: ffmpeg_process.terminate() - async with async_timeout(2.0): + async with asyncio.timeout(2.0): _, stderr = await ffmpeg_process.communicate() logger.debug("Stream command stderr: %s", stderr) - except asyncio.TimeoutError: + except TimeoutError: logger.error( "Timeout while waiting for the stream process " "to terminate. Trying with kill." diff --git a/pyhap/camera_talkback.py b/pyhap/camera_talkback.py index a6c62310..fcfec43c 100644 --- a/pyhap/camera_talkback.py +++ b/pyhap/camera_talkback.py @@ -199,10 +199,7 @@ def _run(self) -> None: try: data, addr = self._sock.recvfrom(2048) except TimeoutError: - # socket.timeout aliases TimeoutError from Python 3.10 on, but - # is an OSError subclass on 3.9, which this project supports, - # so catching TimeoutError here would kill the receiver thread - # on the first (0.5s) poll timeout on Python 3.9. + # The 0.5 s poll timeout, check the stop event again. continue except OSError: break diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 83a47380..5ada36db 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -7,7 +7,6 @@ from collections.abc import Awaitable from http import HTTPStatus import logging -import sys from typing import TYPE_CHECKING, Any, cast from urllib.parse import ParseResult, parse_qs, urlparse import uuid @@ -32,11 +31,6 @@ from .state import State from .util import from_hap_json, to_hap_json -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout - if TYPE_CHECKING: from .accessory_driver import AccessoryDriver @@ -104,7 +98,7 @@ class UnprivilegedRequestException(Exception): async def _run_with_timeout(coro: Awaitable[bytes], timeout: float) -> bytes: """Run a coroutine with a timeout.""" - async with async_timeout(timeout): + async with asyncio.timeout(timeout): return await coro diff --git a/pyhap/hds_server.py b/pyhap/hds_server.py index c059dd8c..785ee888 100644 --- a/pyhap/hds_server.py +++ b/pyhap/hds_server.py @@ -174,7 +174,7 @@ async def drain(self) -> None: """ try: await asyncio.wait_for(self._can_write.wait(), _DRAIN_TIMEOUT) - except asyncio.TimeoutError: + except TimeoutError: logger.info("Aborting HDS connection: peer stopped reading") self.abort() # connection_lost wakes us and cancels the recording streams, so a diff --git a/pyhap/util.py b/pyhap/util.py index ff1200fd..d540ced9 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -5,7 +5,6 @@ import inspect import random import socket -import sys from typing import Any, cast from uuid import UUID @@ -13,11 +12,6 @@ from .const import BASE_UUID -if sys.version_info >= (3, 11): - from asyncio import timeout as async_timeout -else: - from async_timeout import timeout as async_timeout - ALPHANUM = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" HEX_DIGITS = "0123456789ABCDEF" _BACKGROUND_TASKS: set[asyncio.Task[Any]] = set() @@ -147,9 +141,9 @@ async def event_wait(event: asyncio.Event, timeout: float) -> bool: :rtype: bool """ try: - async with async_timeout(timeout): + async with asyncio.timeout(timeout): await event.wait() - except asyncio.TimeoutError: + except TimeoutError: pass return event.is_set() diff --git a/pyproject.toml b/pyproject.toml index f5ce1f42..841aec15 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,14 +8,13 @@ license = "Apache-2.0" description = "HomeKit Accessory Protocol implementation in python" readme = "README.md" authors = [{ name = "Ivan Kalchev" }] -requires-python = ">=3.10" +requires-python = ">=3.11" classifiers = [ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Intended Audience :: End Users/Desktop", "Natural Language :: English", "Operating System :: OS Independent", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", @@ -23,7 +22,6 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "async_timeout;python_version<'3.11'", "cryptography", "chacha20poly1305-reuseable", "orjson>=3.7.2", @@ -61,7 +59,7 @@ version = { attr = "pyhap.const.__version__" } [tool.ruff] line-length = 88 -target-version = "py310" +target-version = "py311" # Directories previously excluded from black. exclude = ["generated"] @@ -146,7 +144,7 @@ include = [ ] [tool.ty.environment] -python-version = "3.10" +python-version = "3.11" [tool.pytest.ini_options] testpaths = [ diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index 28d64911..4b83f040 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -1104,7 +1104,7 @@ async def test_camera_snapshot_times_out(driver: AccessoryDriver) -> None: connections = {} def _get_snapshot(*_: Any) -> NoReturn: - raise asyncio.TimeoutError("timeout") + raise TimeoutError("timeout") acc = Accessory(driver, "TestAcc") # Camera-only snapshot method, attached dynamically so the diff --git a/tox.ini b/tox.ini index cabd6d4a..f245245d 100644 --- a/tox.ini +++ b/tox.ini @@ -1,10 +1,9 @@ [tox] -envlist = py310, py311, py312, py313, docs, lint, ty +envlist = py311, py312, py313, docs, lint, ty skip_missing_interpreters = True [gh-actions] python = - 3.10: py310 3.11: py311 3.12: py312 3.13: py313 @@ -64,8 +63,5 @@ extras = bench deps = -r{toxinidir}/requirements_test.txt - # ty type checks against python-version 3.10, where hap_handler - # imports async_timeout instead of asyncio.timeout. - async_timeout commands = ty check From b648961f8150d60743608bce158c91fcbf3fe50e Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 14:30:57 +0100 Subject: [PATCH 2/4] Test that stop_stream kills ffmpeg after the timeout --- tests/test_camera.py | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/tests/test_camera.py b/tests/test_camera.py index 9d81af8b..2036c683 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -6,7 +6,7 @@ import socket import struct from typing import Any, cast -from unittest.mock import Mock, patch +from unittest.mock import AsyncMock, Mock, patch from uuid import UUID import pytest @@ -420,3 +420,22 @@ async def subprocess_exec(*_args: Any, **_kwargs: Any) -> Mock: ): assert await acc.start_stream(session_info, safe_config) is True exec_mock.assert_called_once() + + +@pytest.mark.asyncio +async def test_stop_stream_kills_after_timeout(mock_driver: Any) -> None: + """Verify stop_stream kills ffmpeg when it does not exit in time.""" + acc = camera.Camera(_OPTIONS, mock_driver, "Camera") + process = Mock() + process.communicate = AsyncMock(side_effect=TimeoutError) + process.wait = AsyncMock(return_value=0) + session_info = { + "id": UUID("7d0d1ee9-46fe-4a56-a115-69df3f6860c1"), + "process": process, + } + + await acc.stop_stream(session_info) + + process.terminate.assert_called_once_with() + process.kill.assert_called_once_with() + process.wait.assert_awaited_once_with() From 3b2e24dde3aff8bfc2dae5a79952d455a915115a Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Tue, 10 Nov 2026 16:11:53 +0100 Subject: [PATCH 3/4] Name Python 3.11 as the minimum in the install guide --- docs/source/intro/install.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/intro/install.rst b/docs/source/intro/install.rst index 0267da94..b1e3b261 100644 --- a/docs/source/intro/install.rst +++ b/docs/source/intro/install.rst @@ -7,7 +7,7 @@ Installation Guide Before We Begin =============== -HAP-python requires Python 3.4+. +HAP-python requires Python 3.11+. This guide will cover the current version of Raspbian and Ubuntu LTS. It is somewhat safe to assume the process for newer versions of Ubuntu will work. From 86dd9b02b4882e3bcc932690c3ff2189fb0b4f05 Mon Sep 17 00:00:00 2001 From: ikalchev Date: Tue, 10 Nov 2026 21:40:11 +0100 Subject: [PATCH 4/4] Catch TimeoutError instead of asyncio.TimeoutError asyncio.TimeoutError is an alias of the builtin TimeoutError since Python 3.11, and ruff (target 3.11) enforces this via UP041. --- pyhap/camera_multi_tier.py | 2 +- pyhap/camera_stream.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyhap/camera_multi_tier.py b/pyhap/camera_multi_tier.py index c9f1f8e1..2cc21dde 100644 --- a/pyhap/camera_multi_tier.py +++ b/pyhap/camera_multi_tier.py @@ -748,7 +748,7 @@ async def handle_end(self, session: MultiTierSession) -> None: process.terminate() try: await asyncio.wait_for(process.wait(), 2.0) - except asyncio.TimeoutError: + except TimeoutError: logger.warning("[%s] Killing the stream process", session.id) with contextlib.suppress(ProcessLookupError): process.kill() diff --git a/pyhap/camera_stream.py b/pyhap/camera_stream.py index 73ce1bad..38099a2c 100644 --- a/pyhap/camera_stream.py +++ b/pyhap/camera_stream.py @@ -340,7 +340,7 @@ async def _release(self, session: _S, hooks: list[asyncio.Task[Any]]) -> bool: ) try: await asyncio.wait_for(self._release_hook(session), self.hook_timeout) - except asyncio.TimeoutError: + except TimeoutError: logger.warning("[%s] Releasing the session timed out", session.id) return False # The delegate may fail in many ways, the session is gone anyway.