From d5455c9594a38ba9e42d4c00ab9e0c7a83b99d81 Mon Sep 17 00:00:00 2001 From: David Bonnes Date: Mon, 2 Sep 2024 19:34:43 +0100 Subject: [PATCH] tweaks & tidy up (json=, not data=), add snake_case framework --- src/evohomeasync/base.py | 6 +++--- src/evohomeasync/broker.py | 14 ++++++------- src/evohomeasync2/base.py | 13 ++++++------ src/evohomeasync2/broker.py | 36 +++++++++++++++++++++++++++++++--- src/evohomeasync2/hotwater.py | 2 +- src/evohomeasync2/location.py | 9 +++++---- src/evohomeasync2/system.py | 2 +- src/evohomeasync2/zone.py | 24 ++++++++++++----------- tests/tests/helpers.py | 4 +--- tests/tests/test_helpers.py | 13 +++++++++++- tests/tests_rf/helpers.py | 4 ++-- tests/tests_rf/test_v2_apis.py | 5 ++--- 12 files changed, 87 insertions(+), 45 deletions(-) diff --git a/src/evohomeasync/base.py b/src/evohomeasync/base.py index 9a29d52..7b96f7c 100644 --- a/src/evohomeasync/base.py +++ b/src/evohomeasync/base.py @@ -331,7 +331,7 @@ async def _set_system_mode( data |= {SZ_QUICK_ACTION_NEXT_TIME: until.strftime("%Y-%m-%dT%H:%M:%SZ")} url = f"evoTouchSystems?locationId={self.location_id}" - await self.broker.make_request(HTTPMethod.PUT, url, data=data) + await self.broker.make_request(HTTPMethod.PUT, url, json=data) async def set_mode_auto(self) -> None: """Set the system to normal operation.""" @@ -416,7 +416,7 @@ async def _set_heat_setpoint( } url = f"devices/{zone_id}/thermostat/changeableValues/heatSetpoint" - await self.broker.make_request(HTTPMethod.PUT, url, data=data) + await self.broker.make_request(HTTPMethod.PUT, url, json=data) async def set_temperature( self, zone: _ZoneIdT | _ZoneNameT, temperature: float, until: dt | None = None @@ -474,7 +474,7 @@ async def _set_dhw( data |= {SZ_NEXT_TIME: next_time.strftime("%Y-%m-%dT%H:%M:%SZ")} url = f"devices/{dhw_id}/thermostat/changeableValues" - await self.broker.make_request(HTTPMethod.PUT, url, data=data) + await self.broker.make_request(HTTPMethod.PUT, url, json=data) async def set_dhw_on(self, until: dt | None = None) -> None: """Set DHW to On, either indefinitely, or until a specified time. diff --git a/src/evohomeasync/broker.py b/src/evohomeasync/broker.py index bdbb276..6d743ec 100644 --- a/src/evohomeasync/broker.py +++ b/src/evohomeasync/broker.py @@ -88,7 +88,7 @@ async def _populate_user_data(self) -> tuple[_UserDataT, aiohttp.ClientResponse] """Return the latest user data as retrieved from the web.""" url = "session" - response = await self.make_request(HTTPMethod.POST, url, data=self._POST_DATA) + response = await self.make_request(HTTPMethod.POST, url, json=self._POST_DATA) self._user_data: _UserDataT = await response.json() @@ -109,7 +109,7 @@ async def populate_full_data(self) -> list[_LocnDataT]: await self.populate_user_data() url = f"locations?userId={self._user_id}&allData=True" - response = await self.make_request(HTTPMethod.GET, url, data=self._POST_DATA) + response = await self.make_request(HTTPMethod.GET, url, json=self._POST_DATA) self._full_data: list[_LocnDataT] = await response.json() @@ -122,7 +122,7 @@ async def _make_request( url: str, /, *, - data: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, _dont_reauthenticate: bool = False, # used only with recursive call ) -> aiohttp.ClientResponse: """Perform an HTTP request, with an optional retry if re-authenticated.""" @@ -138,7 +138,7 @@ async def _make_request( url_ = self.hostname + "/WebAPI/api/" + url - async with func(url_, json=data, headers=self._headers) as r: + async with func(url_, json=json, headers=self._headers) as r: response_text = await r.text() # why cant I move this below the if? # if 401/unauthorized, may need to refresh sessionId (expires in 15 mins?) @@ -170,7 +170,7 @@ async def _make_request( # NOTE: this is a recursive call, used only after (success) re-authenticating return await self._make_request( - method, url, data=data, _dont_reauthenticate=True + method, url, json=json, _dont_reauthenticate=True ) async def make_request( @@ -179,12 +179,12 @@ async def make_request( url: str, /, *, - data: dict[str, Any] | None = None, + json: dict[str, Any] | None = None, ) -> aiohttp.ClientResponse: """Perform an HTTP request, will authenticate if required.""" try: - response = await self._make_request(method, url, data=data) # ? ClientError + response = await self._make_request(method, url, json=json) # ? ClientError response.raise_for_status() # ? ClientResponseError # response.method, response.url, response.status, response._body diff --git a/src/evohomeasync2/base.py b/src/evohomeasync2/base.py index 0e99710..d10a2a8 100644 --- a/src/evohomeasync2/base.py +++ b/src/evohomeasync2/base.py @@ -8,9 +8,10 @@ from typing import TYPE_CHECKING, Any, Final, NoReturn from . import exceptions as exc -from .broker import AbstractTokenManager, Broker +from .broker import AbstractTokenManager, Broker, convert_json from .location import Location from .schema import SCH_FULL_CONFIG, SCH_USER_ACCOUNT +from .schema.const import SZ_USER_ID if TYPE_CHECKING: from datetime import datetime as dt @@ -191,9 +192,8 @@ async def user_account(self, *, force_update: bool = False) -> _EvoDictT: if self._user_account and not force_update: return self._user_account - self._user_account: _EvoDictT = await self.broker.get( - "userAccount", schema=SCH_USER_ACCOUNT - ) # type: ignore[assignment] + result = await self.broker.get("userAccount", schema=SCH_USER_ACCOUNT) + self._user_account: _EvoDictT = convert_json(result) # type: ignore[assignment] return self._user_account # type: ignore[return-value] @@ -228,10 +228,11 @@ async def _installation(self, *, refresh_status: bool = True) -> _EvoListT: # FIXME: shouldn't really be starting again with new objects? self.locations = [] # for now, need to clear this before GET - url = f"location/installationInfo?userId={self.account_info['userId']}" + url = f"location/installationInfo?userId={self.account_info[SZ_USER_ID]}" url += "&includeTemperatureControlSystems=True" - self._full_config = await self.broker.get(url, schema=SCH_FULL_CONFIG) # type: ignore[assignment] + result = await self.broker.get(url, schema=SCH_FULL_CONFIG) + self._full_config: _EvoDictT = convert_json(result) # type: ignore[assignment] # populate each freshly instantiated location with its initial status loc_config: _EvoDictT diff --git a/src/evohomeasync2/broker.py b/src/evohomeasync2/broker.py index 5d7b619..e6c3152 100644 --- a/src/evohomeasync2/broker.py +++ b/src/evohomeasync2/broker.py @@ -4,10 +4,11 @@ from __future__ import annotations import logging +import re from abc import ABC, abstractmethod from datetime import datetime as dt, timedelta as td from http import HTTPMethod, HTTPStatus -from typing import TYPE_CHECKING, Any, Final, TypedDict +from typing import TYPE_CHECKING, Any, Final, TypedDict, TypeVar import aiohttp import voluptuous as vol @@ -51,7 +52,7 @@ } _ERR_MSG_LOOKUP_BASE: dict[int, str] = _ERR_MSG_LOOKUP_BOTH | { # GET/PUT URL_BASE - HTTPStatus.BAD_REQUEST: "Bad request (invalid data/json?)", + HTTPStatus.BAD_REQUEST: "Bad request (invalid json?)", HTTPStatus.NOT_FOUND: "Not Found (invalid entity type?)", HTTPStatus.UNAUTHORIZED: "Unauthorized (expired access token/unknown entity id?)", } @@ -188,7 +189,7 @@ async def _obtain_access_token(self, credentials: dict[str, str]) -> None: token_data = await self._post_access_token_request( AUTH_URL, - data=AUTH_PAYLOAD | credentials, + data=AUTH_PAYLOAD | credentials, # NOTE: here, must be data=, not json= headers=AUTH_HEADER, ) @@ -344,3 +345,32 @@ async def put( ) return content + + +T = TypeVar("T", dict[str, Any], list[Any], dict[str, Any] | list[Any]) + + +def convert_json(node: T) -> T: + """Convert all the strings in a JSON object from camelCase to snake_case.""" + + return node + + def camel_to_snake(value: str, /) -> str: + """Convert a camelCase / PascalCase string to snake_case.""" + + s = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", value) + return re.sub("([a-z0-9])([A-Z])", r"\1_\2", s).lower() + + if isinstance(node, str): + return camel_to_snake(node) + + if isinstance(node, list): + return [convert_json(i) for i in node] + + if not isinstance(node, dict): + return node + + return { + camel_to_snake(k) if isinstance(k, str) else k: convert_json(v) + for k, v in node.items() + } diff --git a/src/evohomeasync2/hotwater.py b/src/evohomeasync2/hotwater.py index 6b7ce17..abe381c 100644 --- a/src/evohomeasync2/hotwater.py +++ b/src/evohomeasync2/hotwater.py @@ -113,7 +113,7 @@ def _next_setpoint(self) -> tuple[dt, str] | None: # WIP: for convenience (new) async def _set_mode(self, mode: dict[str, str | None]) -> None: """Set the DHW mode (state).""" - _ = await self._broker.put(f"{self.TYPE}/{self.id}/state", json=mode) + await self._broker.put(f"{self.TYPE}/{self.id}/state", json=mode) async def reset_mode(self) -> None: """Cancel any override and allow the DHW to follow its schedule.""" diff --git a/src/evohomeasync2/location.py b/src/evohomeasync2/location.py index b0d0c43..b262e4a 100644 --- a/src/evohomeasync2/location.py +++ b/src/evohomeasync2/location.py @@ -7,6 +7,7 @@ from typing import TYPE_CHECKING, Any, Final, NoReturn from . import exceptions as exc +from .broker import convert_json from .gateway import Gateway from .schema import SCH_LOCN_STATUS from .schema.const import ( @@ -110,10 +111,10 @@ def use_daylight_save_switching(self) -> bool: async def refresh_status(self) -> _EvoDictT: """Update the entire Location with its latest status (returns the status).""" - status: _EvoDictT = await self._broker.get( - f"{self.TYPE}/{self.id}/status?includeTemperatureControlSystems=True", - schema=self.STATUS_SCHEMA, - ) # type: ignore[assignment] + url = f"{self.TYPE}/{self.id}/status?includeTemperatureControlSystems=True" + + result = await self._broker.get(url, schema=self.STATUS_SCHEMA) + status: _EvoDictT = convert_json(result) # type: ignore[arg-type] self._update_status(status) return status diff --git a/src/evohomeasync2/system.py b/src/evohomeasync2/system.py index ea4ac1c..de458b3 100644 --- a/src/evohomeasync2/system.py +++ b/src/evohomeasync2/system.py @@ -217,7 +217,7 @@ def zone_by_name(self, name: str) -> Zone | None: async def _set_mode(self, mode: dict[str, str | bool]) -> None: """Set the TCS mode.""" # {'mode': 'Auto', 'isPermanent': True} - _ = await self._broker.put(f"{self.TYPE}/{self.id}/mode", json=mode) + await self._broker.put(f"{self.TYPE}/{self.id}/mode", json=mode) async def reset_mode(self) -> None: """Set the TCS to auto mode (and DHW/all zones to FollowSchedule mode).""" diff --git a/src/evohomeasync2/zone.py b/src/evohomeasync2/zone.py index 78bca36..483f97f 100644 --- a/src/evohomeasync2/zone.py +++ b/src/evohomeasync2/zone.py @@ -14,6 +14,7 @@ import voluptuous as vol from . import exceptions as exc +from .broker import convert_json from .const import API_STRFTIME, ZoneMode from .schema import ( SCH_GET_SCHEDULE_ZONE, @@ -154,9 +155,10 @@ async def _refresh_status(self) -> _EvoDictT: with a single GET. """ - status: _EvoDictT = await self._broker.get( - f"{self.TYPE}/{self.id}/status", schema=self.STATUS_SCHEMA - ) # type: ignore[assignment] + url = f"{self.TYPE}/{self.id}/status" + + result = await self._broker.get(url, schema=self.STATUS_SCHEMA) # XXX + status: _EvoDictT = convert_json(result) # type: ignore[arg-type] self._update_status(status) return status @@ -182,10 +184,10 @@ async def get_schedule(self) -> _EvoDictT: self._logger.debug(f"{self}: Getting schedule...") + url = f"{self.TYPE}/{self.id}/schedule" + try: - schedule: _EvoDictT = await self._broker.get( - f"{self.TYPE}/{self.id}/schedule", schema=self.SCH_SCHEDULE_GET - ) # type: ignore[assignment] + result = await self._broker.get(url, schema=self.SCH_SCHEDULE_GET) # XXX except exc.RequestFailedError as err: if err.status == HTTPStatus.BAD_REQUEST: @@ -199,7 +201,8 @@ async def get_schedule(self) -> _EvoDictT: f"{self}: No Schedule / Schedule is invalid" ) from err - self._schedule = convert_to_put_schedule(schedule) + # TODO: convert_json() + self._schedule = convert_to_put_schedule(result) # type: ignore[arg-type] return self._schedule async def set_schedule(self, schedule: _EvoDictT | str) -> None: @@ -228,7 +231,7 @@ async def set_schedule(self, schedule: _EvoDictT | str) -> None: f"{self}: Invalid schedule type: {type(schedule)}" ) - _ = await self._broker.put( + await self._broker.put( f"{self.TYPE}/{self.id}/schedule", json=schedule, schema=self.SCH_SCHEDULE_PUT, @@ -347,9 +350,8 @@ def target_heat_temperature(self) -> float | None: # TODO: no provision for cooling async def _set_mode(self, mode: dict[str, str | float]) -> None: - """Set the zone mode (heat_setpoint, cooling is TBD).""" - # TODO: also coolSetpoint - _ = await self._broker.put(f"{self.TYPE}/{self.id}/heatSetpoint", json=mode) + """Set the zone mode (heat_setpoint, cooling is TBD).""" # TODO: coolSetpoint + await self._broker.put(f"{self.TYPE}/{self.id}/heatSetpoint", json=mode) async def reset_mode(self) -> None: """Cancel any override and allow the zone to follow its schedule""" diff --git a/tests/tests/helpers.py b/tests/tests/helpers.py index d991534..ec0605e 100644 --- a/tests/tests/helpers.py +++ b/tests/tests/helpers.py @@ -67,9 +67,7 @@ def fixture_file(folder: Path, file_name: str, /) -> dict: pytest.skip(f"Fixture {file_name} not found in {folder.name}") with (folder / file_name).open() as f: - data: dict = json.load(f) - - return data + return json.load(f) # type: ignore[no-any-return] def refresh_config_with_status(config: dict, status: dict) -> None: diff --git a/tests/tests/test_helpers.py b/tests/tests/test_helpers.py index 68da198..980967c 100644 --- a/tests/tests/test_helpers.py +++ b/tests/tests/test_helpers.py @@ -5,6 +5,9 @@ import json +import pytest + +from evohomeasync2.broker import convert_json from evohomeasync2.schema.helpers import camel_case, pascal_case from evohomeasync2.schema.schedule import ( SCH_GET_SCHEDULE_DHW, @@ -48,7 +51,7 @@ def test_get_schedule_dhw() -> None: assert get_schedule == convert_to_get_schedule(put_schedule) -def test_helper_function() -> None: +def test_case_converters() -> None: """Test helper functions.""" camel_case_str = "testString" @@ -61,3 +64,11 @@ def test_helper_function() -> None: assert camel_case(pascal_case(camel_case_str)) == camel_case_str assert pascal_case(camel_case(pascal_case_str)) == pascal_case_str + + +@pytest.mark.skip("This is a WIP") +def test_json_snakator() -> None: + """Confirm the recursive snake_case converter works as expected.""" + + assert convert_json({"UpperCase": "lowerCase"}) == {"upper_case": "lower_case"} + assert convert_json({"UpperCase": ["lowerCase"]}) == {"upper_case": ["lower_case"]} diff --git a/tests/tests_rf/helpers.py b/tests/tests_rf/helpers.py index 1e56ff8..d4552cc 100644 --- a/tests/tests_rf/helpers.py +++ b/tests/tests_rf/helpers.py @@ -64,7 +64,7 @@ async def should_work_v1( # noqa: PLR0913 response: aiohttp.ClientResponse # unlike _make_request(), make_request() incl. raise_for_status() - response = await evo.broker._make_request(method, url, data=json) + response = await evo.broker._make_request(method, url, json=json) response.raise_for_status() # TODO: perform this transform in the broker @@ -97,7 +97,7 @@ async def should_fail_v1( # noqa: PLR0913 try: # unlike _make_request(), make_request() incl. raise_for_status() - response = await evo.broker._make_request(method, url, data=json) + response = await evo.broker._make_request(method, url, json=json) response.raise_for_status() except aiohttp.ClientResponseError as err: diff --git a/tests/tests_rf/test_v2_apis.py b/tests/tests_rf/test_v2_apis.py index 554f87c..a9e0b5a 100644 --- a/tests/tests_rf/test_v2_apis.py +++ b/tests/tests_rf/test_v2_apis.py @@ -181,9 +181,8 @@ async def test_basics( ) -> None: """Test authentication, `user_account()` and `installation()`.""" - await _test_basics_apis( - await instantiate_client_v2(user_credentials, session, dont_login=True) - ) + evo2 = await instantiate_client_v2(user_credentials, session, dont_login=True) + await _test_basics_apis(evo2) async def test_sched_(evo2: Awaitable[ev2.EvohomeClient]) -> None: