Skip to content

Commit

Permalink
tweaks & tidy up (json=, not data=), add snake_case framework
Browse files Browse the repository at this point in the history
  • Loading branch information
zxdavb committed Sep 2, 2024
1 parent 4cced87 commit d5455c9
Show file tree
Hide file tree
Showing 12 changed files with 87 additions and 45 deletions.
6 changes: 3 additions & 3 deletions src/evohomeasync/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
14 changes: 7 additions & 7 deletions src/evohomeasync/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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()

Expand All @@ -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."""
Expand All @@ -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?)
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
13 changes: 7 additions & 6 deletions src/evohomeasync2/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand Down
36 changes: 33 additions & 3 deletions src/evohomeasync2/broker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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?)",
}
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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()
}
2 changes: 1 addition & 1 deletion src/evohomeasync2/hotwater.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
9 changes: 5 additions & 4 deletions src/evohomeasync2/location.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/evohomeasync2/system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)."""
Expand Down
24 changes: 13 additions & 11 deletions src/evohomeasync2/zone.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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"""
Expand Down
4 changes: 1 addition & 3 deletions tests/tests/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion tests/tests/test_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"
Expand All @@ -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"]}
4 changes: 2 additions & 2 deletions tests/tests_rf/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down
5 changes: 2 additions & 3 deletions tests/tests_rf/test_v2_apis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down

0 comments on commit d5455c9

Please sign in to comment.