Skip to content

Commit 3b714ee

Browse files
authored
Refactor and implement feedback on v2 (#1973)
Implement feedback from #1931
1 parent 8eb08cf commit 3b714ee

7 files changed

Lines changed: 169 additions & 31 deletions

File tree

pyoverkiz/action_queue.py

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@
88
from dataclasses import dataclass
99
from typing import TYPE_CHECKING, Any
1010

11+
from pyoverkiz.models import Action
12+
1113
if TYPE_CHECKING:
1214
from pyoverkiz.enums import CommandMode
13-
from pyoverkiz.models import Action
1415

1516

1617
@dataclass(frozen=True, slots=True)
@@ -108,6 +109,15 @@ def __init__(
108109
self._flush_task: asyncio.Task[None] | None = None
109110
self._lock = asyncio.Lock()
110111

112+
@staticmethod
113+
def _copy_action(action: Action) -> Action:
114+
"""Return an `Action` copy with an independent commands list.
115+
116+
The queue merges commands for duplicate devices, so caller-owned action
117+
instances must be copied to avoid mutating user input while batching.
118+
"""
119+
return Action(device_url=action.device_url, commands=list(action.commands))
120+
111121
async def add(
112122
self,
113123
actions: list[Action],
@@ -142,8 +152,9 @@ async def add(
142152
for action in actions:
143153
existing = normalized_index.get(action.device_url)
144154
if existing is None:
145-
normalized_actions.append(action)
146-
normalized_index[action.device_url] = action
155+
action_copy = self._copy_action(action)
156+
normalized_actions.append(action_copy)
157+
normalized_index[action.device_url] = action_copy
147158
else:
148159
existing.commands.extend(action.commands)
149160

@@ -155,17 +166,15 @@ async def add(
155166
batches_to_execute.append(self._prepare_flush())
156167

157168
# Add actions to pending queue
169+
pending_index = {
170+
pending_action.device_url: pending_action
171+
for pending_action in self._pending_actions
172+
}
158173
for action in normalized_actions:
159-
pending = next(
160-
(
161-
pending_action
162-
for pending_action in self._pending_actions
163-
if pending_action.device_url == action.device_url
164-
),
165-
None,
166-
)
174+
pending = pending_index.get(action.device_url)
167175
if pending is None:
168176
self._pending_actions.append(action)
177+
pending_index[action.device_url] = action
169178
else:
170179
pending.commands.extend(action.commands)
171180
self._pending_mode = mode

pyoverkiz/client.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -598,7 +598,16 @@ async def get_action_groups(self) -> list[ActionGroup]:
598598

599599
@retry_on_auth_error
600600
async def get_places(self) -> Place:
601-
"""List the places."""
601+
"""Get the hierarchical structure of places (house, rooms, areas, zones).
602+
603+
The Place model represents a hierarchical organization where the root place is
604+
typically the house/property, and `sub_places` contains nested child places
605+
(floors, rooms, areas). This structure can be recursively navigated to build
606+
a complete map of all locations in the setup. Each place has:
607+
- `label`: Human-readable name for the place
608+
- `type`: Numeric identifier for the place type
609+
- `sub_places`: List of nested places within this location
610+
"""
602611
response = await self._get("setup/places")
603612
places = Place(**humps.decamelize(response))
604613
return places
@@ -739,6 +748,11 @@ async def _get(self, path: str) -> Any:
739748
ssl=self._ssl,
740749
) as response:
741750
await self.check_response(response)
751+
752+
# 204 has no body.
753+
if response.status == 204:
754+
return None
755+
742756
return await response.json()
743757

744758
async def _post(
@@ -756,6 +770,9 @@ async def _post(
756770
ssl=self._ssl,
757771
) as response:
758772
await self.check_response(response)
773+
# 204 has no body.
774+
if response.status == 204:
775+
return None
759776
return await response.json()
760777

761778
async def _delete(self, path: str) -> None:

pyoverkiz/models.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
from __future__ import annotations
44

5+
import json
6+
import logging
57
import re
68
from collections.abc import Iterator
79
from typing import Any, cast
@@ -31,7 +33,11 @@
3133
# pylint: disable=unused-argument, too-many-instance-attributes, too-many-locals
3234

3335
# <protocol>://<gatewayId>/<deviceAddress>[#<subsystemId>]
34-
DEVICE_URL_RE = r"(?P<protocol>.+)://(?P<gatewayId>[^/]+)/(?P<deviceAddress>[^#]+)(#(?P<subsystemId>\d+))?"
36+
DEVICE_URL_RE = re.compile(
37+
r"(?P<protocol>[^:]+)://(?P<gatewayId>[^/]+)/(?P<deviceAddress>[^#]+)(#(?P<subsystemId>\d+))?"
38+
)
39+
40+
_LOGGER = logging.getLogger(__name__)
3541

3642

3743
@define(init=False, kw_only=True)
@@ -180,7 +186,7 @@ def is_sub_device(self) -> bool:
180186
@classmethod
181187
def from_device_url(cls, device_url: str) -> DeviceIdentifier:
182188
"""Parse a device URL into its structured identifier components."""
183-
match = re.search(DEVICE_URL_RE, device_url)
189+
match = DEVICE_URL_RE.fullmatch(device_url)
184190
if not match:
185191
raise ValueError(f"Invalid device URL: {device_url}")
186192

@@ -589,8 +595,24 @@ def __init__(
589595
# Overkiz (cloud) returns all state values as a string
590596
# We cast them here based on the data type provided by Overkiz
591597
# Overkiz (local) returns all state values in the right format
592-
if isinstance(self.value, str) and self.type in DATA_TYPE_TO_PYTHON:
593-
self.value = DATA_TYPE_TO_PYTHON[self.type](self.value)
598+
if not isinstance(self.value, str) or self.type not in DATA_TYPE_TO_PYTHON:
599+
return
600+
601+
caster = DATA_TYPE_TO_PYTHON[self.type]
602+
if self.type in (DataType.JSON_ARRAY, DataType.JSON_OBJECT):
603+
self.value = self._cast_json_value(self.value)
604+
return
605+
606+
self.value = caster(self.value)
607+
608+
def _cast_json_value(self, raw_value: str) -> StateType:
609+
"""Cast JSON event state values; raise on decode errors."""
610+
try:
611+
return json.loads(raw_value)
612+
except json.JSONDecodeError as err:
613+
raise ValueError(
614+
f"Invalid JSON for event state `{self.name}` ({self.type.name}): {err}"
615+
) from err
594616

595617

596618
@define(init=False)
@@ -1059,7 +1081,13 @@ def __init__(
10591081

10601082
@define(init=False, kw_only=True)
10611083
class Place:
1062-
"""Representation of a place (house/room) in a setup."""
1084+
"""Hierarchical representation of a location (house, room, area) in a setup.
1085+
1086+
Places form a tree structure where the root place is typically the entire house
1087+
or property, and `sub_places` contains nested child locations. This recursive
1088+
structure allows navigation from house -> floors/rooms -> individual areas.
1089+
Each place has associated metadata like creation time, label, and type identifier.
1090+
"""
10631091

10641092
creation_time: int
10651093
last_update_time: int | None = None

pyoverkiz/serializers.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,24 @@
1616
_ABBREV_MAP: dict[str, str] = {"deviceUrl": "deviceURL"}
1717

1818

19-
def _fix_abbreviations(obj: Any) -> Any:
20-
if isinstance(obj, dict):
21-
out = {}
22-
for k, v in obj.items():
23-
k = _ABBREV_MAP.get(k, k)
24-
out[k] = _fix_abbreviations(v)
25-
return out
26-
if isinstance(obj, list):
27-
return [_fix_abbreviations(i) for i in obj]
28-
return obj
19+
def _camelize_key(key: str) -> str:
20+
"""Camelize a single key and apply abbreviation fixes in one step."""
21+
camel = humps.camelize(key)
22+
return _ABBREV_MAP.get(camel, camel)
2923

3024

3125
def prepare_payload(payload: Any) -> Any:
3226
"""Convert snake_case payload to API-ready camelCase and apply fixes.
3327
28+
Performs camelization and abbreviation fixes in a single recursive pass
29+
to avoid walking the structure twice.
30+
3431
Example:
3532
payload = {"device_url": "x", "commands": [{"name": "close"}]}
3633
=> {"deviceURL": "x", "commands": [{"name": "close"}]}
3734
"""
38-
camel = humps.camelize(payload)
39-
return _fix_abbreviations(camel)
35+
if isinstance(payload, dict):
36+
return {_camelize_key(k): prepare_payload(v) for k, v in payload.items()}
37+
if isinstance(payload, list):
38+
return [prepare_payload(item) for item in payload]
39+
return payload

tests/test_action_queue.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,29 @@ async def test_action_queue_duplicate_device_merge_order(mock_executor):
214214
]
215215

216216

217+
@pytest.mark.asyncio
218+
async def test_action_queue_duplicate_device_merge_does_not_mutate_inputs(
219+
mock_executor,
220+
):
221+
"""Test that merge behavior does not mutate caller-owned Action commands."""
222+
queue = ActionQueue(executor=mock_executor, delay=0.1)
223+
224+
action1 = Action(
225+
device_url="io://1234-5678-9012/1",
226+
commands=[Command(name=OverkizCommand.CLOSE)],
227+
)
228+
action2 = Action(
229+
device_url="io://1234-5678-9012/1",
230+
commands=[Command(name=OverkizCommand.OPEN)],
231+
)
232+
233+
queued = await queue.add([action1, action2])
234+
await queued
235+
236+
assert [command.name for command in action1.commands] == [OverkizCommand.CLOSE]
237+
assert [command.name for command in action2.commands] == [OverkizCommand.OPEN]
238+
239+
217240
@pytest.mark.asyncio
218241
async def test_action_queue_manual_flush(mock_executor):
219242
"""Test manual flush of the queue."""

tests/test_client.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,40 @@ async def test_get_setup_options(
480480
for option in options:
481481
assert isinstance(option, Option)
482482

483+
@pytest.mark.asyncio
484+
async def test_get_returns_none_for_204_without_json_parse(
485+
self, client: OverkizClient
486+
) -> None:
487+
"""Ensure `_get` skips JSON parsing for 204 responses and returns `None`."""
488+
resp = MockResponse("", status=204)
489+
resp.json = AsyncMock(return_value={})
490+
491+
with (
492+
patch.object(client, "_refresh_token_if_expired", new=AsyncMock()),
493+
patch.object(aiohttp.ClientSession, "get", return_value=resp),
494+
):
495+
result = await client._get("setup/options")
496+
497+
assert result is None
498+
assert not resp.json.called
499+
500+
@pytest.mark.asyncio
501+
async def test_post_returns_none_for_204_without_json_parse(
502+
self, client: OverkizClient
503+
) -> None:
504+
"""Ensure `_post` skips JSON parsing for 204 responses and returns `None`."""
505+
resp = MockResponse("", status=204)
506+
resp.json = AsyncMock(return_value={})
507+
508+
with (
509+
patch.object(client, "_refresh_token_if_expired", new=AsyncMock()),
510+
patch.object(aiohttp.ClientSession, "post", return_value=resp),
511+
):
512+
result = await client._post("setup/devices/states/refresh")
513+
514+
assert result is None
515+
assert not resp.json.called
516+
483517
@pytest.mark.asyncio
484518
async def test_execute_action_group_omits_none_fields(self, client: OverkizClient):
485519
"""Ensure `type` and `parameters` that are None are omitted from the request payload."""

tests/test_models.py

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
CommandDefinitions,
1111
Definition,
1212
Device,
13+
EventState,
1314
State,
1415
StateDefinition,
1516
States,
@@ -183,11 +184,18 @@ def test_base_url_parsing(
183184
assert device.identifier.subsystem_id == subsystem_id
184185
assert device.identifier.is_sub_device == is_sub_device
185186

186-
def test_invalid_device_url_raises(self):
187+
@pytest.mark.parametrize(
188+
"device_url",
189+
[
190+
"foo://whatever",
191+
"io://1234-5678-9012/10077486#8 trailing",
192+
],
193+
)
194+
def test_invalid_device_url_raises(self, device_url: str):
187195
"""Invalid device URLs should raise during identifier parsing."""
188196
test_device = {
189197
**RAW_DEVICES,
190-
**{"deviceURL": "foo://whatever"},
198+
**{"deviceURL": device_url},
191199
}
192200
hump_device = humps.decamelize(test_device)
193201

@@ -578,6 +586,25 @@ def test_bad_list_value(self):
578586
assert state.value_as_list
579587

580588

589+
class TestEventState:
590+
"""Unit tests for EventState cloud payload casting behavior."""
591+
592+
def test_json_string_is_parsed(self):
593+
"""Valid JSON payload strings are cast to typed Python values."""
594+
state = EventState(name="state", type=DataType.JSON_OBJECT, value='{"foo": 1}')
595+
596+
assert state.value == {"foo": 1}
597+
598+
def test_invalid_json_string_raises(self):
599+
"""Malformed JSON payload strings raise ValueError."""
600+
with pytest.raises(ValueError, match="Invalid JSON for event state"):
601+
EventState(
602+
name="state",
603+
type=DataType.JSON_ARRAY,
604+
value="[not-valid-json",
605+
)
606+
607+
581608
def test_command_to_payload_omits_none():
582609
"""Command.to_payload omits None fields from the resulting payload."""
583610
from pyoverkiz.enums.command import OverkizCommand

0 commit comments

Comments
 (0)