diff --git a/poetry.lock b/poetry.lock index 669dfa2..574e8b2 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1040,14 +1040,14 @@ files = [ [[package]] name = "saic-ismart-client-ng" -version = "0.9.3" +version = "0.10.0" description = "SAIC next gen client library (MG iSMART)" optional = false python-versions = ">=3.11" groups = ["main"] files = [ - {file = "saic_ismart_client_ng-0.9.3-py3-none-any.whl", hash = "sha256:8737a5879263cbfca81dc4147e7d6c5388732a565d0b11cf1cd79be3bfe4d248"}, - {file = "saic_ismart_client_ng-0.9.3.tar.gz", hash = "sha256:5d0f7af2df7275b33f8995c1d5e90e0a52e99961f9860450bae9bbfac73019ad"}, + {file = "saic_ismart_client_ng-0.10.0-py3-none-any.whl", hash = "sha256:4cbe793c5fcbdaf6880cf865fcaa0f03154cd5c91b4bb38a902c2d193f94c82c"}, + {file = "saic_ismart_client_ng-0.10.0.tar.gz", hash = "sha256:d4517c6c1e979471f5631b0836665f02352bb699c55309dff50a03df15077709"}, ] [package.dependencies] @@ -1149,4 +1149,4 @@ python-discovery = ">=1.2.2" [metadata] lock-version = "2.1" python-versions = ">=3.12,<4.0" -content-hash = "2f857bf23052a67c88f8f78ebf3698a9327bfc3333cc22984bf8ed1bf127c4bf" +content-hash = "01464256bc61d0c23e2733a2d72d8892c74d7053d050100090a3c6f936c80f4d" diff --git a/pyproject.toml b/pyproject.toml index 8f35e6a..c686fb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ requires-python = '>=3.12,<4.0' dependencies = [ - "saic-ismart-client-ng (>=0.9.3,<0.10.0)", + "saic-ismart-client-ng (>=0.10.0,<0.11.0)", 'httpx (>=0.28.1,<0.29.0)', 'inflection (>=0.5.1,<0.6.0)', 'apscheduler (>=3.11.0,<4.0.0)', diff --git a/src/mqtt_gateway.py b/src/mqtt_gateway.py index 40b8f5b..ae17992 100644 --- a/src/mqtt_gateway.py +++ b/src/mqtt_gateway.py @@ -134,7 +134,16 @@ async def run(self) -> None: self.__scheduler.start() LOG.info("Entering main loop") - await self.__run_until_all_tasks_done() + try: + await self.__run_until_all_tasks_done() + finally: + await self.__close_saic_api() + + async def __close_saic_api(self) -> None: + try: + await self.saic_api.close() + except Exception: + LOG.warning("Failed to close the SAIC API client", exc_info=True) async def __fetch_user_timezone(self) -> ZoneInfo | None: try: diff --git a/tests/test_gateway_shutdown.py b/tests/test_gateway_shutdown.py new file mode 100644 index 0000000..a40939f --- /dev/null +++ b/tests/test_gateway_shutdown.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import unittest +from unittest.mock import AsyncMock, patch + +import pytest + +from configuration import Configuration +from mqtt_gateway import MqttGateway + +from .mocks import MessageCapturingConsolePublisher + + +def _make_gateway() -> MqttGateway: + config = Configuration() + config.saic_user = "user@example.com" + config.saic_password = "secret" # noqa: S105 + with patch( + "mqtt_gateway.MqttGateway._MqttGateway__select_publisher", + return_value=MessageCapturingConsolePublisher(config), + ): + return MqttGateway(config) + + +class TestGatewayShutdown(unittest.IsolatedAsyncioTestCase): + async def _run_with_main_loop( + self, gateway: MqttGateway, main_loop: AsyncMock + ) -> None: + with ( + patch.object(gateway, "_MqttGateway__do_initial_login", AsyncMock()), + patch.object(gateway, "_MqttGateway__run_until_all_tasks_done", main_loop), + patch.object(gateway, "_MqttGateway__scheduler", create=True), + ): + await gateway.run() + + async def test_api_client_closed_when_main_loop_exits(self) -> None: + gateway = _make_gateway() + gateway.saic_api.close = AsyncMock() # type: ignore[method-assign] + + with pytest.raises(SystemExit): + await self._run_with_main_loop( + gateway, AsyncMock(side_effect=SystemExit(-1)) + ) + + gateway.saic_api.close.assert_awaited_once() + + async def test_close_failure_is_logged_not_raised(self) -> None: + gateway = _make_gateway() + gateway.saic_api.close = AsyncMock(side_effect=RuntimeError("boom")) # type: ignore[method-assign] + + with self.assertLogs("mqtt_gateway", level="WARNING"): + await self._run_with_main_loop(gateway, AsyncMock()) + + gateway.saic_api.close.assert_awaited_once()