From 8dd9f8bc31937c481a6d39012485949413444bfd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 16 Jun 2025 12:28:26 +0200 Subject: [PATCH 1/6] Modernize packaging --- .github/workflows/ci.yaml | 21 ++++++------ MANIFEST.in | 3 +- pyhap/const.py | 1 - pyproject.toml | 53 +++++++++++++++++++++++++++++- scripts/release | 12 +++---- setup.py | 68 --------------------------------------- tox.ini | 9 ++---- 7 files changed, 71 insertions(+), 96 deletions(-) delete mode 100644 setup.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f8ee3dda..fc243e44 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -8,13 +8,14 @@ jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: - python-version: [3.7, 3.8, 3.9, "3.10", "3.11", "3.12"] + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -35,14 +36,14 @@ jobs: strategy: matrix: - python-version: ["3.10"] + python-version: ["3.13"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: - python-version: ${{ matrix.python-version }} + python-version: ${{ matrix.python-version }} - name: Install dependencies run: | python -m pip install --upgrade pip @@ -66,12 +67,12 @@ jobs: strategy: matrix: - python-version: ["3.10"] + python-version: ["3.13"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v4 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/MANIFEST.in b/MANIFEST.in index 350d0849..69576498 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1 @@ -include README.md LICENSE -recursive-include pyhap/resources * \ No newline at end of file +recursive-include pyhap/resources * diff --git a/pyhap/const.py b/pyhap/const.py index 148beb4b..72e9b731 100644 --- a/pyhap/const.py +++ b/pyhap/const.py @@ -4,7 +4,6 @@ PATCH_VERSION = 2 __short_version__ = f"{MAJOR_VERSION}.{MINOR_VERSION}" __version__ = f"{__short_version__}.{PATCH_VERSION}" -REQUIRED_PYTHON_VER = (3, 7) BASE_UUID = "-0000-1000-8000-0026BB765291" diff --git a/pyproject.toml b/pyproject.toml index 4c31e9c5..6da10036 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,56 @@ +[build-system] +build-backend = "setuptools.build_meta" +requires = ["setuptools>=77.0"] + +[project] +name = "HAP-python" +license = "Apache-2.0" +description = "HomeKit Accessory Protocol implementation in python" +readme = "README.md" +authors = [{ name = "Ivan Kalchev" }] +requires-python = ">=3.9" +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.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Home Automation", + "Topic :: Software Development :: Libraries :: Python Modules", +] +dependencies = [ + "async_timeout", + "cryptography", + "chacha20poly1305-reuseable", + "orjson>=3.7.2", + "zeroconf>=0.36.2", + "h11", +] +dynamic = ["version"] + +[project.optional-dependencies] +QRCode = [ + "base36", + "pyqrcode", +] + +[project.urls] +"Source" = "https://github.com/ikalchev/HAP-python" +"Bug Reports" = "https://github.com/ikalchev/HAP-python/issues" +"Documentation" = "http://hap-python.readthedocs.io/en/latest/" + +[tool.setuptools.packages.find] +include = ["pyhap*"] + +[tool.setuptools.dynamic] +version = { attr = "pyhap.const.__version__" } + [tool.black] -target-version = ["py35", "py36", "py37", "py38"] exclude = 'generated' [tool.isort] diff --git a/scripts/release b/scripts/release index 64e489a4..8f336bf7 100755 --- a/scripts/release +++ b/scripts/release @@ -11,15 +11,11 @@ if [ -n "$(ls | grep 'build')" ]; then rm -r build/ fi -echo "=====================================" -echo "= Generation source distribution =" -echo "=====================================" -python3 setup.py sdist -echo "====================================" -echo "= Generation build distribution =" -echo "====================================" -python3 setup.py bdist_wheel +echo "==============================================" +echo "= Generate source and build distributions =" +echo "==============================================" +python3 -m build echo "=====================" echo "= Upload to pypi =" diff --git a/setup.py b/setup.py deleted file mode 100644 index 778226ca..00000000 --- a/setup.py +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env python3 -from setuptools import setup - -import pyhap.const as pyhap_const - -NAME = "HAP-python" -DESCRIPTION = "HomeKit Accessory Protocol implementation in python" -URL = "https://github.com/ikalchev/{}".format(NAME) -AUTHOR = "Ivan Kalchev" - - -PROJECT_URLS = { - "Bug Reports": "{}/issues".format(URL), - "Documentation": "http://hap-python.readthedocs.io/en/latest/", - "Source": "{}/tree/master".format(URL), -} - - -MIN_PY_VERSION = ".".join(map(str, pyhap_const.REQUIRED_PYTHON_VER)) - -with open("README.md", "r", encoding="utf-8") as f: - README = f.read() - - -REQUIRES = [ - "async_timeout", - "cryptography", - "chacha20poly1305-reuseable", - "orjson>=3.7.2", - "zeroconf>=0.36.2", - "h11", -] - - -setup( - name=NAME, - version=pyhap_const.__version__, - description=DESCRIPTION, - long_description=README, - long_description_content_type="text/markdown", - url=URL, - packages=["pyhap"], - include_package_data=True, - project_urls=PROJECT_URLS, - python_requires=">={}".format(MIN_PY_VERSION), - install_requires=REQUIRES, - license="Apache-2.0", - license_file="LICENSE", - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: Apache Software License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Programming Language :: Python :: 3.7", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Topic :: Home Automation", - "Topic :: Software Development :: Libraries :: Python Modules", - ], - extras_require={ - "QRCode": ["base36", "pyqrcode"], - }, -) diff --git a/tox.ini b/tox.ini index c8ef3f22..4142281e 100644 --- a/tox.ini +++ b/tox.ini @@ -1,17 +1,14 @@ [tox] -envlist = py35, py36, py37, py38, py39, py310, py311, py312, docs, lint, pylint, bandit +envlist = py39, py310, py311, py312, py313, docs, lint, pylint, bandit skip_missing_interpreters = True [gh-actions] python = - 3.5: py36 - 3.6: py36 - 3.7: py37 - 3.8: py38, mypy 3.9: py39, mypy 3.10: py310, mypy 3.11: py311, mypy 3.12: py312, mypy + 3.13: py313, mypy [testenv] deps = @@ -28,7 +25,7 @@ commands = pytest --timeout=2 --cov --cov-report=xml {posargs} [testenv:temperature] -basepython = python3.6 +basepython = python3.12 deps = -r{toxinidir}/requirements_all.txt commands = From d81b008eb44bcc71554d36d6794105f777f45e1b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:38:21 +0200 Subject: [PATCH 2/6] Fix tests --- pyproject.toml | 1 + setup.cfg | 3 --- tests/conftest.py | 4 ++-- tests/test_accessory.py | 2 +- tests/test_accessory_driver.py | 23 ++++++++--------------- tests/test_hap_handler.py | 30 +++++++++++++++--------------- tests/test_hap_protocol.py | 11 ----------- tests/test_hap_server.py | 7 ------- 8 files changed, 27 insertions(+), 54 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 6da10036..14cec753 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -164,3 +164,4 @@ norecursedirs = [ ".git", "testing_config", ] +asyncio_mode = "auto" diff --git a/setup.cfg b/setup.cfg index 2a2577e8..97094410 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,2 @@ -[tool:pytest] -testpaths = tests - [pycodestyle] max-line-length = 90 diff --git a/tests/conftest.py b/tests/conftest.py index c6c6bedd..0be012ae 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ """Test fictures and mocks.""" import asyncio -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest @@ -35,7 +35,7 @@ def driver(async_zeroconf): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) with patch( - "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock ), patch( "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock ), patch( diff --git a/tests/test_accessory.py b/tests/test_accessory.py index 8e0adfa6..d28b5a5f 100644 --- a/tests/test_accessory.py +++ b/tests/test_accessory.py @@ -28,6 +28,7 @@ class TestAccessory(Accessory): """An accessory that keeps track of if its stopped.""" + __test__ = False def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -536,7 +537,6 @@ def test_to_hap_standalone(mock_driver): } -@pytest.mark.asyncio async def test_bridge_run_stop(): with patch( "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock diff --git a/tests/test_accessory_driver.py b/tests/test_accessory_driver.py index 88061ed1..cfc630b5 100644 --- a/tests/test_accessory_driver.py +++ b/tests/test_accessory_driver.py @@ -553,7 +553,8 @@ def setup_message(self): acc = Acc(driver, "TestAcc") driver.add_accessory(acc) - driver.start() + with patch.object(driver.loop, "close"): + driver.start() def test_accessory_level_callbacks(driver: AccessoryDriver): @@ -763,10 +764,9 @@ def test_accessory_level_callbacks_with_a_failure(driver: AccessoryDriver): } -@pytest.mark.asyncio async def test_start_stop_sync_acc(async_zeroconf): with patch( - "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock ), patch( "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock ), patch( @@ -795,11 +795,10 @@ def setup_message(self): assert not driver.loop.is_closed() -@pytest.mark.asyncio async def test_start_stop_async_acc(async_zeroconf): """Verify run_at_interval closes the driver.""" with patch( - "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock ), patch( "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock ), patch( @@ -858,10 +857,9 @@ def setup_message(self): assert not driver.loop.is_closed() -@pytest.mark.asyncio async def test_start_from_async_stop_from_executor(async_zeroconf): with patch( - "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock ), patch( "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock ), patch( @@ -1041,11 +1039,10 @@ def test_mdns_name_sanity( assert mdns_info.server == mdns_server -@pytest.mark.asyncio async def test_start_service_and_update_config(async_zeroconf): """Test starting service and updating the config.""" with patch( - "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock ), patch( "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock ), patch( @@ -1078,7 +1075,6 @@ def test_call_add_job_with_none(driver): driver.add_job(None) -@pytest.mark.asyncio async def test_call_async_add_job_with_coroutine(driver): """Test calling async_add_job with a coroutine.""" with patch("pyhap.accessory_driver.HAPServer"), patch( @@ -1099,7 +1095,6 @@ async def coro_test(): assert called is True -@pytest.mark.asyncio async def test_call_async_add_job_with_callback(driver, async_zeroconf): """Test calling async_add_job with a coroutine.""" with patch("pyhap.accessory_driver.HAPServer"), patch( @@ -1119,10 +1114,9 @@ def callback_test(): assert called is True -@pytest.mark.asyncio async def test_bridge_with_multiple_async_run_at_interval_accessories(async_zeroconf): with patch( - "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock ), patch( "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock ), patch( @@ -1149,10 +1143,9 @@ async def test_bridge_with_multiple_async_run_at_interval_accessories(async_zero assert acc3.counter > 2 -@pytest.mark.asyncio async def test_bridge_with_multiple_sync_run_at_interval_accessories(async_zeroconf): with patch( - "pyhap.accessory_driver.HAPServer.async_stop", new_callable=AsyncMock + "pyhap.accessory_driver.HAPServer.async_stop", new_callable=MagicMock ), patch( "pyhap.accessory_driver.HAPServer.async_start", new_callable=AsyncMock ), patch( diff --git a/tests/test_hap_handler.py b/tests/test_hap_handler.py index 75cd0d4f..963cffa1 100644 --- a/tests/test_hap_handler.py +++ b/tests/test_hap_handler.py @@ -32,7 +32,7 @@ def test_response(): assert "500" in str(response) -def test_list_pairings_unencrypted(driver: AccessoryDriver): +async def test_list_pairings_unencrypted(driver: AccessoryDriver): """Verify an unencrypted list pairings request fails.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -57,7 +57,7 @@ def test_list_pairings_unencrypted(driver: AccessoryDriver): } -def test_list_pairings(driver: AccessoryDriver): +async def test_list_pairings(driver: AccessoryDriver): """Verify an encrypted list pairings request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -84,7 +84,7 @@ def test_list_pairings(driver: AccessoryDriver): } -def test_list_pairings_multiple(driver: AccessoryDriver): +async def test_list_pairings_multiple(driver: AccessoryDriver): """Verify an encrypted list pairings request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -117,7 +117,7 @@ def test_list_pairings_multiple(driver: AccessoryDriver): } -def test_add_pairing_admin(driver: AccessoryDriver): +async def test_add_pairing_admin(driver: AccessoryDriver): """Verify an encrypted add pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -148,7 +148,7 @@ def test_add_pairing_admin(driver: AccessoryDriver): assert driver.state.is_admin(CLIENT2_UUID) -def test_add_pairing_user(driver: AccessoryDriver): +async def test_add_pairing_user(driver: AccessoryDriver): """Verify an encrypted add pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -221,7 +221,7 @@ def test_add_pairing_user(driver: AccessoryDriver): assert not driver.state.is_admin(CLIENT2_UUID) -def test_remove_pairing(driver: AccessoryDriver): +async def test_remove_pairing(driver: AccessoryDriver): """Verify an encrypted remove pairing request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -272,7 +272,7 @@ def test_remove_pairing(driver: AccessoryDriver): assert driver.state.paired is False -def test_non_admin_pairings_request(driver: AccessoryDriver): +async def test_non_admin_pairings_request(driver: AccessoryDriver): """Verify only admins can access pairings.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -296,7 +296,7 @@ def test_non_admin_pairings_request(driver: AccessoryDriver): } -def test_invalid_pairings_request(driver: AccessoryDriver): +async def test_invalid_pairings_request(driver: AccessoryDriver): """Verify an encrypted invalid pairings request.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -317,7 +317,7 @@ def test_invalid_pairings_request(driver: AccessoryDriver): handler.handle_pairings() -def test_pair_verify_one(driver: AccessoryDriver): +async def test_pair_verify_one(driver: AccessoryDriver): """Verify an unencrypted pair verify one.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -369,7 +369,7 @@ def test_pair_verify_one_not_paired(driver: AccessoryDriver): } -def test_pair_verify_two_invalid_state(driver: AccessoryDriver): +async def test_pair_verify_two_invalid_state(driver: AccessoryDriver): """Verify an unencrypted pair verify two.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -413,7 +413,7 @@ def test_pair_verify_two_invalid_state(driver: AccessoryDriver): } -def test_pair_verify_two_missing_signature(driver: AccessoryDriver): +async def test_pair_verify_two_missing_signature(driver: AccessoryDriver): """Verify a pair verify two with a missing signature.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -466,7 +466,7 @@ def test_pair_verify_two_missing_signature(driver: AccessoryDriver): } -def test_pair_verify_two_success_raw_uuid_bytes_missing(driver: AccessoryDriver): +async def test_pair_verify_two_success_raw_uuid_bytes_missing(driver: AccessoryDriver): """Verify a pair verify two populated missing raw bytes.""" driver.add_accessory(Accessory(driver, "TestAcc")) client_private_key = ed25519.Ed25519PrivateKey.generate() @@ -549,7 +549,7 @@ def test_pair_verify_two_success_raw_uuid_bytes_missing(driver: AccessoryDriver) assert driver.state.uuid_to_bytes[CLIENT_UUID] == CLIENT_UUID_BYTES -def test_pair_verify_two_success(driver: AccessoryDriver): +async def test_pair_verify_two_success(driver: AccessoryDriver): """Verify a pair verify two.""" driver.add_accessory(Accessory(driver, "TestAcc")) client_private_key = ed25519.Ed25519PrivateKey.generate() @@ -627,7 +627,7 @@ def test_pair_verify_two_success(driver: AccessoryDriver): assert driver.state.uuid_to_bytes[CLIENT_UUID] == CLIENT_UUID_BYTES -def test_invalid_pairing_request(driver: AccessoryDriver): +async def test_invalid_pairing_request(driver: AccessoryDriver): """Verify an unencrypted pair verify with an invalid sequence fails.""" driver.add_accessory(Accessory(driver, "TestAcc")) @@ -926,7 +926,7 @@ def test_handle_snapshot_encrypted_non_existant_accessory(driver: AccessoryDrive handler.handle_resource() -def test_attempt_to_pair_when_already_paired(driver: AccessoryDriver): +async def test_attempt_to_pair_when_already_paired(driver: AccessoryDriver): """Verify we respond with unavailable if already paired.""" driver.add_accessory(Accessory(driver, "TestAcc")) diff --git a/tests/test_hap_protocol.py b/tests/test_hap_protocol.py index ca121083..76a930e8 100644 --- a/tests/test_hap_protocol.py +++ b/tests/test_hap_protocol.py @@ -4,7 +4,6 @@ from unittest.mock import MagicMock, Mock, patch from cryptography.exceptions import InvalidTag -import pytest from pyhap import hap_handler, hap_protocol from pyhap.accessory import Accessory, Bridge @@ -388,7 +387,6 @@ def test_http_11_keep_alive(driver): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_connection_closed(driver): """Test camera snapshot when the other side closes the connection.""" loop = MagicMock() @@ -447,7 +445,6 @@ def test_camera_snapshot_without_snapshot_support(driver): assert b"-70402" in b"".join(writelines.call_args_list[0][0]) -@pytest.mark.asyncio async def test_camera_snapshot_works_sync(driver): """Test camera snapshot works if there is support for it.""" loop = MagicMock() @@ -480,7 +477,6 @@ def _get_snapshot(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_works_async(driver): """Test camera snapshot works if there is support for it.""" loop = MagicMock() @@ -513,7 +509,6 @@ async def _async_get_snapshot(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_timeout_async(driver): """Test camera snapshot timeout is handled.""" loop = MagicMock() @@ -580,7 +575,6 @@ def _make_response(*_): hap_proto.close() -@pytest.mark.asyncio async def test_pairing_changed(driver): """Test we update mdns when the pairing changes.""" loop = MagicMock() @@ -618,7 +612,6 @@ def _make_response(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_throws_an_exception(driver): """Test camera snapshot that throws an exception.""" loop = MagicMock() @@ -654,7 +647,6 @@ async def _async_get_snapshot(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_times_out(driver): """Test camera snapshot times out.""" loop = MagicMock() @@ -690,7 +682,6 @@ def _get_snapshot(*_): hap_proto.close() -@pytest.mark.asyncio async def test_camera_snapshot_missing_accessory(driver): """Test camera snapshot that throws an exception.""" loop = MagicMock() @@ -717,7 +708,6 @@ async def test_camera_snapshot_missing_accessory(driver): hap_proto.close() -@pytest.mark.asyncio async def test_idle_timeout(driver): """Test we close the connection once we reach the idle timeout.""" loop = asyncio.get_event_loop() @@ -739,7 +729,6 @@ async def test_idle_timeout(driver): assert hap_proto_close.called is True -@pytest.mark.asyncio async def test_does_not_timeout(driver): """Test we do not timeout the connection if we have not reached the idle.""" loop = asyncio.get_event_loop() diff --git a/tests/test_hap_server.py b/tests/test_hap_server.py index 46c8884d..1297ab4d 100644 --- a/tests/test_hap_server.py +++ b/tests/test_hap_server.py @@ -3,15 +3,12 @@ import asyncio from unittest.mock import MagicMock, patch -import pytest - from pyhap import hap_server from pyhap.accessory import Accessory from pyhap.accessory_driver import AccessoryDriver from pyhap.hap_protocol import HAPServerProtocol -@pytest.mark.asyncio async def test_we_can_start_stop(driver): """Test we can start and stop.""" loop = asyncio.get_event_loop() @@ -26,7 +23,6 @@ async def test_we_can_start_stop(driver): server.async_stop() -@pytest.mark.asyncio async def test_we_can_connect(): """Test we can start, connect, and stop.""" loop = asyncio.get_event_loop() @@ -52,7 +48,6 @@ async def test_we_can_connect(): writer.close() -@pytest.mark.asyncio async def test_idle_connection_cleanup(): """Test we cleanup idle connections.""" loop = asyncio.get_event_loop() @@ -79,7 +74,6 @@ async def test_idle_connection_cleanup(): server.async_stop() -@pytest.mark.asyncio async def test_push_event(driver): """Test we can create and send an event.""" addr_info = ("1.2.3.4", 1234) @@ -151,7 +145,6 @@ def _save_event(hap_event): ] -@pytest.mark.asyncio async def test_push_event_overwrites_old_pending_events(driver): """Test push event overwrites old events in the event queue. From 8dcbb95b50ff17f310b7b6303a2c6c2e629e71ed Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 16 Jun 2025 13:43:38 +0200 Subject: [PATCH 3/6] Update formatting with black 25.1.0 --- pyhap/accessory.py | 4 +- pyhap/accessory_driver.py | 11 +- pyhap/camera.py | 688 ++++++++++++++++++--------------- pyhap/characteristic.py | 1 + pyhap/const.py | 1 + pyhap/encoder.py | 1 + pyhap/hap_crypto.py | 1 + pyhap/hap_handler.py | 7 +- pyhap/hap_protocol.py | 1 + pyhap/iid_manager.py | 1 + pyhap/loader.py | 1 + pyhap/state.py | 3 +- pyhap/tlv.py | 3 +- tests/conftest.py | 10 +- tests/test_accessory.py | 18 +- tests/test_accessory_driver.py | 105 +++-- tests/test_camera.py | 2 + tests/test_characteristic.py | 1 + tests/test_encoder.py | 1 + tests/test_hap_crypto.py | 1 - tests/test_hap_protocol.py | 33 +- tests/test_hap_server.py | 14 +- tests/test_iid_manager.py | 1 + tests/test_loader.py | 1 + tests/test_service.py | 30 +- tests/test_state.py | 37 +- tests/test_util.py | 1 + 27 files changed, 544 insertions(+), 434 deletions(-) diff --git a/pyhap/accessory.py b/pyhap/accessory.py index 89ecda8b..afbccce4 100644 --- a/pyhap/accessory.py +++ b/pyhap/accessory.py @@ -1,4 +1,5 @@ """Module for the Accessory classes.""" + import itertools import logging from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional @@ -207,7 +208,8 @@ def xhm_uri(self) -> str: int(self.driver.state.pincode.replace(b"-", b""), 10) & 0x7FFFFFFF ) # pincode - encoded_payload = base36.dumps(payload).upper() # pylint: disable=possibly-used-before-assignment + # pylint: disable-next=possibly-used-before-assignment + encoded_payload = base36.dumps(payload).upper() encoded_payload = encoded_payload.rjust(9, "0") return "X-HM://" + encoded_payload + self.driver.state.setup_id diff --git a/pyhap/accessory_driver.py b/pyhap/accessory_driver.py index b2b2f4bb..782f492a 100644 --- a/pyhap/accessory_driver.py +++ b/pyhap/accessory_driver.py @@ -15,6 +15,7 @@ terminates the call chain and concludes the publishing process from the Characteristic, the Characteristic does not block waiting for the actual send to happen. """ + import asyncio import base64 from collections import defaultdict @@ -212,7 +213,7 @@ def __init__( advertised_address=None, interface_choice=None, async_zeroconf_instance=None, - zeroconf_server=None + zeroconf_server=None, ): """ Initialize a new AccessoryDriver object. @@ -323,7 +324,7 @@ def start(self): and os.name != "nt" ): logger.debug("Setting child watcher") - watcher = asyncio.SafeChildWatcher() # pylint: disable=deprecated-class + watcher = asyncio.SafeChildWatcher() # pylint: disable=deprecated-class watcher.attach_loop(self.loop) asyncio.set_child_watcher(watcher) else: @@ -647,7 +648,11 @@ def persist(self): mode="w", dir=temp_dir, delete=False ) as file_handle: tmp_filename = file_handle.name - logger.debug("Created temp persist file '%s' named '%s'", file_handle, tmp_filename) + logger.debug( + "Created temp persist file '%s' named '%s'", + file_handle, + tmp_filename, + ) self.encoder.persist(file_handle, self.state) if ( os.name == "nt" diff --git a/pyhap/camera.py b/pyhap/camera.py index a33e7aee..64675891 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -32,195 +32,195 @@ from pyhap.util import byte_bool, to_base64_str SETUP_TYPES = { - 'SESSION_ID': b'\x01', - 'STATUS': b'\x02', - 'ADDRESS': b'\x03', - 'VIDEO_SRTP_PARAM': b'\x04', - 'AUDIO_SRTP_PARAM': b'\x05', - 'VIDEO_SSRC': b'\x06', - 'AUDIO_SSRC': b'\x07' + "SESSION_ID": b"\x01", + "STATUS": b"\x02", + "ADDRESS": b"\x03", + "VIDEO_SRTP_PARAM": b"\x04", + "AUDIO_SRTP_PARAM": b"\x05", + "VIDEO_SSRC": b"\x06", + "AUDIO_SSRC": b"\x07", } SETUP_STATUS = { - 'SUCCESS': b'\x00', - 'BUSY': b'\x01', - 'ERROR': b'\x02' + "SUCCESS": b"\x00", + "BUSY": b"\x01", + "ERROR": b"\x02", } SETUP_IPV = { - 'IPV4': b'\x00', - 'IPV6': b'\x01' + "IPV4": b"\x00", + "IPV6": b"\x01", } SETUP_ADDR_INFO = { - 'ADDRESS_VER': b'\x01', - 'ADDRESS': b'\x02', - 'VIDEO_RTP_PORT': b'\x03', - 'AUDIO_RTP_PORT': b'\x04' + "ADDRESS_VER": b"\x01", + "ADDRESS": b"\x02", + "VIDEO_RTP_PORT": b"\x03", + "AUDIO_RTP_PORT": b"\x04", } SETUP_SRTP_PARAM = { - 'CRYPTO': b'\x01', - 'MASTER_KEY': b'\x02', - 'MASTER_SALT': b'\x03' + "CRYPTO": b"\x01", + "MASTER_KEY": b"\x02", + "MASTER_SALT": b"\x03", } STREAMING_STATUS = { - 'AVAILABLE': b'\x00', - 'STREAMING': b'\x01', - 'BUSY': b'\x02' + "AVAILABLE": b"\x00", + "STREAMING": b"\x01", + "BUSY": b"\x02", } RTP_CONFIG_TYPES = { - 'CRYPTO': b'\x02' + "CRYPTO": b"\x02", } SRTP_CRYPTO_SUITES = { - 'AES_CM_128_HMAC_SHA1_80': b'\x00', - 'AES_CM_256_HMAC_SHA1_80': b'\x01', - 'NONE': b'\x02' + "AES_CM_128_HMAC_SHA1_80": b"\x00", + "AES_CM_256_HMAC_SHA1_80": b"\x01", + "NONE": b"\x02", } VIDEO_TYPES = { - 'CODEC': b'\x01', - 'CODEC_PARAM': b'\x02', - 'ATTRIBUTES': b'\x03', - 'RTP_PARAM': b'\x04' + "CODEC": b"\x01", + "CODEC_PARAM": b"\x02", + "ATTRIBUTES": b"\x03", + "RTP_PARAM": b"\x04", } VIDEO_CODEC_TYPES = { - 'H264': b'\x00' + "H264": b"\x00", } VIDEO_CODEC_PARAM_TYPES = { - 'PROFILE_ID': b'\x01', - 'LEVEL': b'\x02', - 'PACKETIZATION_MODE': b'\x03', - 'CVO_ENABLED': b'\x04', - 'CVO_ID': b'\x05' + "PROFILE_ID": b"\x01", + "LEVEL": b"\x02", + "PACKETIZATION_MODE": b"\x03", + "CVO_ENABLED": b"\x04", + "CVO_ID": b"\x05", } VIDEO_CODEC_PARAM_CVO_TYPES = { - 'UNSUPPORTED': b'\x01', - 'SUPPORTED': b'\x02' + "UNSUPPORTED": b"\x01", + "SUPPORTED": b"\x02", } VIDEO_CODEC_PARAM_PROFILE_ID_TYPES = { - 'BASELINE': b'\x00', - 'MAIN': b'\x01', - 'HIGH': b'\x02' + "BASELINE": b"\x00", + "MAIN": b"\x01", + "HIGH": b"\x02", } VIDEO_CODEC_PARAM_LEVEL_TYPES = { - 'TYPE3_1': b'\x00', - 'TYPE3_2': b'\x01', - 'TYPE4_0': b'\x02' + "TYPE3_1": b"\x00", + "TYPE3_2": b"\x01", + "TYPE4_0": b"\x02", } VIDEO_CODEC_PARAM_PACKETIZATION_MODE_TYPES = { - 'NON_INTERLEAVED': b'\x00' + "NON_INTERLEAVED": b"\x00", } VIDEO_ATTRIBUTES_TYPES = { - 'IMAGE_WIDTH': b'\x01', - 'IMAGE_HEIGHT': b'\x02', - 'FRAME_RATE': b'\x03' + "IMAGE_WIDTH": b"\x01", + "IMAGE_HEIGHT": b"\x02", + "FRAME_RATE": b"\x03", } -SUPPORTED_VIDEO_CONFIG_TAG = b'\x01' +SUPPORTED_VIDEO_CONFIG_TAG = b"\x01" SELECTED_STREAM_CONFIGURATION_TYPES = { - 'SESSION': b'\x01', - 'VIDEO': b'\x02', - 'AUDIO': b'\x03' + "SESSION": b"\x01", + "VIDEO": b"\x02", + "AUDIO": b"\x03", } RTP_PARAM_TYPES = { - 'PAYLOAD_TYPE': b'\x01', - 'SYNCHRONIZATION_SOURCE': b'\x02', - 'MAX_BIT_RATE': b'\x03', - 'RTCP_SEND_INTERVAL': b'\x04', - 'MAX_MTU': b'\x05', - 'COMFORT_NOISE_PAYLOAD_TYPE': b'\x06' + "PAYLOAD_TYPE": b"\x01", + "SYNCHRONIZATION_SOURCE": b"\x02", + "MAX_BIT_RATE": b"\x03", + "RTCP_SEND_INTERVAL": b"\x04", + "MAX_MTU": b"\x05", + "COMFORT_NOISE_PAYLOAD_TYPE": b"\x06", } AUDIO_TYPES = { - 'CODEC': b'\x01', - 'CODEC_PARAM': b'\x02', - 'RTP_PARAM': b'\x03', - 'COMFORT_NOISE': b'\x04' + "CODEC": b"\x01", + "CODEC_PARAM": b"\x02", + "RTP_PARAM": b"\x03", + "COMFORT_NOISE": b"\x04", } AUDIO_CODEC_TYPES = { - 'PCMU': b'\x00', - 'PCMA': b'\x01', - 'AACELD': b'\x02', - 'OPUS': b'\x03' + "PCMU": b"\x00", + "PCMA": b"\x01", + "AACELD": b"\x02", + "OPUS": b"\x03", } AUDIO_CODEC_PARAM_TYPES = { - 'CHANNEL': b'\x01', - 'BIT_RATE': b'\x02', - 'SAMPLE_RATE': b'\x03', - 'PACKET_TIME': b'\x04' + "CHANNEL": b"\x01", + "BIT_RATE": b"\x02", + "SAMPLE_RATE": b"\x03", + "PACKET_TIME": b"\x04", } AUDIO_CODEC_PARAM_BIT_RATE_TYPES = { - 'VARIABLE': b'\x00', - 'CONSTANT': b'\x01' + "VARIABLE": b"\x00", + "CONSTANT": b"\x01", } AUDIO_CODEC_PARAM_SAMPLE_RATE_TYPES = { - 'KHZ_8': b'\x00', - 'KHZ_16': b'\x01', - 'KHZ_24': b'\x02' + "KHZ_8": b"\x00", + "KHZ_16": b"\x01", + "KHZ_24": b"\x02", } -SUPPORTED_AUDIO_CODECS_TAG = b'\x01' -SUPPORTED_COMFORT_NOISE_TAG = b'\x02' -SUPPORTED_AUDIO_CONFIG_TAG = b'\x02' -SET_CONFIG_REQUEST_TAG = b'\x02' -SESSION_ID = b'\x01' +SUPPORTED_AUDIO_CODECS_TAG = b"\x01" +SUPPORTED_COMFORT_NOISE_TAG = b"\x02" +SUPPORTED_AUDIO_CONFIG_TAG = b"\x02" +SET_CONFIG_REQUEST_TAG = b"\x02" +SESSION_ID = b"\x01" -NO_SRTP = b'\x01\x01\x02\x02\x00\x03\x00' -'''Configuration value for no SRTP.''' +NO_SRTP = b"\x01\x01\x02\x02\x00\x03\x00" +"""Configuration value for no SRTP.""" FFMPEG_CMD = ( - 'ffmpeg -re -f avfoundation -framerate {fps} -i 0:0 -threads 0 ' - '-vcodec libx264 -an -pix_fmt yuv420p -r {fps} -f rawvideo -tune zerolatency ' - '-vf scale={width}:{height} -b:v {v_max_bitrate}k -bufsize {v_max_bitrate}k ' - '-payload_type 99 -ssrc {v_ssrc} -f rtp ' - '-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} ' - 'srtp://{address}:{v_port}?rtcpport={v_port}&' - 'localrtcpport={v_port}&pkt_size=1378' + "ffmpeg -re -f avfoundation -framerate {fps} -i 0:0 -threads 0 " + "-vcodec libx264 -an -pix_fmt yuv420p -r {fps} -f rawvideo -tune zerolatency " + "-vf scale={width}:{height} -b:v {v_max_bitrate}k -bufsize {v_max_bitrate}k " + "-payload_type 99 -ssrc {v_ssrc} -f rtp " + "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} " + "srtp://{address}:{v_port}?rtcpport={v_port}&" + "localrtcpport={v_port}&pkt_size=1378" ) -'''Template for the ffmpeg command.''' +"""Template for the ffmpeg command.""" logger = logging.getLogger(__name__) @@ -242,10 +242,10 @@ def get_supported_rtp_config(support_srtp): :type support_srtp: bool """ if support_srtp: - crypto = SRTP_CRYPTO_SUITES['AES_CM_128_HMAC_SHA1_80'] + crypto = SRTP_CRYPTO_SUITES["AES_CM_128_HMAC_SHA1_80"] else: - crypto = SRTP_CRYPTO_SUITES['NONE'] - return tlv.encode(RTP_CONFIG_TYPES['CRYPTO'], crypto, to_base64=True) + crypto = SRTP_CRYPTO_SUITES["NONE"] + return tlv.encode(RTP_CONFIG_TYPES["CRYPTO"], crypto, to_base64=True) @staticmethod def get_supported_video_stream_config(video_params): @@ -259,31 +259,41 @@ def get_supported_video_stream_config(video_params): :type video_params: dict """ codec_params_tlv = tlv.encode( - VIDEO_CODEC_PARAM_TYPES['PACKETIZATION_MODE'], - VIDEO_CODEC_PARAM_PACKETIZATION_MODE_TYPES['NON_INTERLEAVED']) + VIDEO_CODEC_PARAM_TYPES["PACKETIZATION_MODE"], + VIDEO_CODEC_PARAM_PACKETIZATION_MODE_TYPES["NON_INTERLEAVED"], + ) - codec_params = video_params['codec'] - for profile in codec_params['profiles']: - codec_params_tlv += \ - tlv.encode(VIDEO_CODEC_PARAM_TYPES['PROFILE_ID'], profile) + codec_params = video_params["codec"] + for profile in codec_params["profiles"]: + codec_params_tlv += tlv.encode( + VIDEO_CODEC_PARAM_TYPES["PROFILE_ID"], profile + ) - for level in codec_params['levels']: - codec_params_tlv += \ - tlv.encode(VIDEO_CODEC_PARAM_TYPES['LEVEL'], level) + for level in codec_params["levels"]: + codec_params_tlv += tlv.encode(VIDEO_CODEC_PARAM_TYPES["LEVEL"], level) - attr_tlv = b'' - for resolution in video_params['resolutions']: + attr_tlv = b"" + for resolution in video_params["resolutions"]: res_tlv = tlv.encode( - VIDEO_ATTRIBUTES_TYPES['IMAGE_WIDTH'], struct.pack(' Date: Mon, 16 Jun 2025 13:46:03 +0200 Subject: [PATCH 4/6] Ignore flake8 error --- tests/test_camera.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_camera.py b/tests/test_camera.py index c69daeed..9adcfc87 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -87,7 +87,7 @@ def test_setup_endpoints(mock_driver): def test_set_selected_stream_start_stop(mock_driver): - """Test starting a stream request.""" + """Test starting a stream request.""" # noqa: D202 # mocks for asyncio.Process async def communicate(): From 8d815aa9bb61a3132a1af9bc2dfac5f102b384f4 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 16 Jun 2025 14:08:46 +0200 Subject: [PATCH 5/6] Remove deprecated html_theme_path option --- docs/source/conf.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/source/conf.py b/docs/source/conf.py index 0e484bb4..9b725964 100644 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -87,8 +87,6 @@ # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". -import sphinx_rtd_theme -html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] # Custom sidebar templates, must be a dictionary that maps document names # to template names. From 4925b0eb7446db7fec878cb087b638e5279e832e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 16 Jun 2025 16:21:04 +0200 Subject: [PATCH 6/6] Only require async_timeout for Python<3.11 --- pyhap/camera.py | 10 +++++++--- pyhap/hap_handler.py | 9 +++++++-- pyhap/util.py | 9 +++++++-- pyproject.toml | 2 +- requirements_all.txt | 1 + 5 files changed, 23 insertions(+), 8 deletions(-) diff --git a/pyhap/camera.py b/pyhap/camera.py index 64675891..f77de394 100644 --- a/pyhap/camera.py +++ b/pyhap/camera.py @@ -22,15 +22,19 @@ import logging import os import struct +import sys from uuid import UUID -import async_timeout - from pyhap import RESOURCE_DIR, tlv from pyhap.accessory import Accessory from pyhap.const import CATEGORY_CAMERA from pyhap.util import byte_bool, 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", @@ -937,7 +941,7 @@ async def stop_stream(self, session_info): logger.info("[%s] Stopping stream.", session_id) try: ffmpeg_process.terminate() - async with async_timeout.timeout(2.0): + async with async_timeout(2.0): _, stderr = await ffmpeg_process.communicate() logger.debug("Stream command stderr: %s", stderr) except asyncio.TimeoutError: diff --git a/pyhap/hap_handler.py b/pyhap/hap_handler.py index 251dcdb7..9460d52f 100644 --- a/pyhap/hap_handler.py +++ b/pyhap/hap_handler.py @@ -6,11 +6,11 @@ import asyncio from http import HTTPStatus import logging +import sys from typing import TYPE_CHECKING, Any, Dict, Optional from urllib.parse import ParseResult, parse_qs, urlparse import uuid -import async_timeout from chacha20poly1305_reuseable import ChaCha20Poly1305Reusable as ChaCha20Poly1305 from cryptography.exceptions import InvalidSignature, InvalidTag from cryptography.hazmat.primitives import serialization @@ -31,6 +31,11 @@ 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 @@ -98,7 +103,7 @@ class UnprivilegedRequestException(Exception): async def _run_with_timeout(coro, timeout: float) -> bytes: """Run a coroutine with a timeout.""" - async with async_timeout.timeout(timeout): + async with async_timeout(timeout): return await coro diff --git a/pyhap/util.py b/pyhap/util.py index 4ee35386..e6cb6164 100644 --- a/pyhap/util.py +++ b/pyhap/util.py @@ -3,14 +3,19 @@ import functools import random import socket +import sys from typing import Awaitable, Set from uuid import UUID -import async_timeout import orjson 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] = set() @@ -139,7 +144,7 @@ async def event_wait(event, timeout): :rtype: bool """ try: - async with async_timeout.timeout(timeout): + async with async_timeout(timeout): await event.wait() except asyncio.TimeoutError: pass diff --git a/pyproject.toml b/pyproject.toml index 14cec753..676bc6e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "async_timeout", + "async_timeout;python_version<'3.11'", "cryptography", "chacha20poly1305-reuseable", "orjson>=3.7.2", diff --git a/requirements_all.txt b/requirements_all.txt index 89560833..fd69f306 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,3 +1,4 @@ +async_timeout;python_version<'3.11' base36 cryptography orjson