Skip to content

Commit f6427e5

Browse files
authored
Add structure_response helper to consolidate response deserialization (#2024)
## Summary - Introduces `structure_response` helper in `pyoverkiz/converter.py` that combines `decamelize` + `converter.structure` into a single call - Replaces all repeated `converter.structure(decamelize(response), Type)` patterns in `client.py` and tests with the new helper - Uses PEP 695 type parameter syntax (`def structure_response[T](...)`) ## Test plan - [x] All 127 existing model tests pass - [x] Ruff linting passes
1 parent 4c8ee77 commit f6427e5

3 files changed

Lines changed: 40 additions & 36 deletions

File tree

pyoverkiz/client.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,10 @@
2121
)
2222
from backoff.types import Details
2323

24-
from pyoverkiz._case import decamelize
2524
from pyoverkiz.action_queue import ActionQueue, ActionQueueSettings
2625
from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy
2726
from pyoverkiz.const import SUPPORTED_SERVERS, USER_AGENT
28-
from pyoverkiz.converter import converter
27+
from pyoverkiz.converter import converter, structure_response
2928
from pyoverkiz.enums import APIType, ExecutionMode, Protocol, Server
3029
from pyoverkiz.exceptions import (
3130
ExecutionQueueFullError,
@@ -325,7 +324,7 @@ async def get_setup(self, refresh: bool = False) -> Setup:
325324

326325
response = await self._get("setup")
327326

328-
setup = converter.structure(decamelize(response), Setup)
327+
setup = structure_response(response, Setup)
329328

330329
# Cache response
331330
self.setup = setup
@@ -373,7 +372,7 @@ async def get_devices(self, refresh: bool = False) -> list[Device]:
373372
return self.devices
374373

375374
response = await self._get("setup/devices")
376-
devices = converter.structure(decamelize(response), list[Device])
375+
devices = structure_response(response, list[Device])
377376

378377
# Cache response
379378
self.devices = devices
@@ -392,7 +391,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]:
392391
return self.gateways
393392

394393
response = await self._get("setup/gateways")
395-
gateways = converter.structure(decamelize(response), list[Gateway])
394+
gateways = structure_response(response, list[Gateway])
396395

397396
# Cache response
398397
self.gateways = gateways
@@ -405,7 +404,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]:
405404
async def get_execution_history(self) -> list[HistoryExecution]:
406405
"""List past executions and their outcomes."""
407406
response = await self._get("history/executions")
408-
return converter.structure(decamelize(response), list[HistoryExecution])
407+
return structure_response(response, list[HistoryExecution])
409408

410409
@retry_on_auth_error
411410
async def get_device_definition(self, deviceurl: str) -> dict[str, Any] | None:
@@ -422,7 +421,7 @@ async def get_state(self, deviceurl: str) -> list[State]:
422421
response = await self._get(
423422
f"setup/devices/{urllib.parse.quote_plus(deviceurl)}/states"
424423
)
425-
return converter.structure(decamelize(response), list[State])
424+
return structure_response(response, list[State])
426425

427426
@retry_on_auth_error
428427
async def refresh_states(self) -> None:
@@ -465,7 +464,7 @@ async def fetch_events(self) -> list[Event]:
465464
operation (polling).
466465
"""
467466
response = await self._post(f"events/{self.event_listener_id}/fetch")
468-
return converter.structure(decamelize(response), list[Event])
467+
return structure_response(response, list[Event])
469468

470469
async def unregister_event_listener(self) -> None:
471470
"""Unregister an event listener.
@@ -485,13 +484,13 @@ async def get_current_execution(self, exec_id: str) -> Execution | None:
485484
if not response or not isinstance(response, dict):
486485
return None
487486

488-
return converter.structure(decamelize(response), Execution)
487+
return structure_response(response, Execution)
489488

490489
@retry_on_auth_error
491490
async def get_current_executions(self) -> list[Execution]:
492491
"""Get all currently running executions."""
493492
response = await self._get("exec/current")
494-
return converter.structure(decamelize(response), list[Execution])
493+
return structure_response(response, list[Execution])
495494

496495
@retry_on_auth_error
497496
async def get_api_version(self) -> str:
@@ -629,7 +628,7 @@ async def cancel_execution(self, exec_id: str) -> None:
629628
async def get_action_groups(self) -> list[PersistedActionGroup]:
630629
"""List action groups persisted on the server."""
631630
response = await self._get("actionGroups")
632-
return converter.structure(decamelize(response), list[PersistedActionGroup])
631+
return structure_response(response, list[PersistedActionGroup])
633632

634633
@retry_on_auth_error
635634
async def get_places(self) -> Place:
@@ -644,7 +643,7 @@ async def get_places(self) -> Place:
644643
- `sub_places`: List of nested places within this location
645644
"""
646645
response = await self._get("setup/places")
647-
return converter.structure(decamelize(response), Place)
646+
return structure_response(response, Place)
648647

649648
@retry_on_auth_error
650649
async def execute_persisted_action_group(self, oid: str) -> str:
@@ -666,7 +665,7 @@ async def get_setup_options(self) -> list[Option]:
666665
Access scope : Full enduser API access (enduser/*).
667666
"""
668667
response = await self._get("setup/options")
669-
return converter.structure(decamelize(response), list[Option])
668+
return structure_response(response, list[Option])
670669

671670
@retry_on_auth_error
672671
async def get_setup_option(self, option: str) -> Option | None:
@@ -677,7 +676,7 @@ async def get_setup_option(self, option: str) -> Option | None:
677676
response = await self._get(f"setup/options/{option}")
678677

679678
if response:
680-
return converter.structure(decamelize(response), Option)
679+
return structure_response(response, Option)
681680

682681
return None
683682

@@ -695,7 +694,7 @@ async def get_setup_option_parameter(
695694
response = await self._get(f"setup/options/{option}/{parameter}")
696695

697696
if response:
698-
return converter.structure(decamelize(response), OptionParameter)
697+
return structure_response(response, OptionParameter)
699698

700699
return None
701700

@@ -762,7 +761,7 @@ async def get_reference_ui_profile(self, profile_name: str) -> UIProfileDefiniti
762761
response = await self._get(
763762
f"reference/ui/profile/{urllib.parse.quote_plus(profile_name)}"
764763
)
765-
return converter.structure(decamelize(response), UIProfileDefinition)
764+
return structure_response(response, UIProfileDefinition)
766765

767766
@retry_on_auth_error
768767
async def get_reference_ui_profile_names(self) -> list[str]:
@@ -778,7 +777,7 @@ async def get_reference_ui_widgets(self) -> list[str]:
778777
async def get_devices_not_up_to_date(self) -> list[Device]:
779778
"""Get all devices whose firmware is not up to date."""
780779
response = await self._get("setup/devices/notUpToDate")
781-
return converter.structure(decamelize(response), list[Device])
780+
return structure_response(response, list[Device])
782781

783782
@retry_on_auth_error
784783
async def get_device_firmware_status(self, deviceurl: str) -> FirmwareStatus | None:
@@ -792,7 +791,7 @@ async def get_device_firmware_status(self, deviceurl: str) -> FirmwareStatus | N
792791
)
793792
except UnsupportedOperationError:
794793
return None
795-
return converter.structure(decamelize(response), FirmwareStatus)
794+
return structure_response(response, FirmwareStatus)
796795

797796
@retry_on_auth_error
798797
async def get_device_firmware_update_capability(self, deviceurl: str) -> bool:

pyoverkiz/converter.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import attr
1010
import cattrs
1111

12+
from pyoverkiz._case import decamelize
1213
from pyoverkiz.models import (
1314
CommandDefinition,
1415
CommandDefinitions,
@@ -65,3 +66,8 @@ def _structure_command_definitions(val: Any, _: type) -> CommandDefinitions:
6566

6667

6768
converter = _make_converter()
69+
70+
71+
def structure_response[T](data: Any, cls: type[T]) -> T:
72+
"""Decamelize an API response and structure it into the target type."""
73+
return converter.structure(decamelize(data), cls)

tests/test_models.py

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
import cattrs.errors
99
import pytest
1010

11-
from pyoverkiz._case import decamelize
12-
from pyoverkiz.converter import converter
11+
from pyoverkiz.converter import converter, structure_response
1312
from pyoverkiz.enums import DataType, EventName, ExecutionState, FailureType, Protocol
1413
from pyoverkiz.models import (
1514
Action,
@@ -88,7 +87,7 @@
8887

8988
def _make_device(raw: dict | None = None) -> Device:
9089
"""Create a Device from raw camelCase dict via the converter."""
91-
return converter.structure(decamelize(raw or RAW_DEVICES), Device)
90+
return structure_response(raw or RAW_DEVICES, Device)
9291

9392

9493
class TestSetup:
@@ -99,7 +98,7 @@ def test_id_is_raw_but_repr_is_redacted_when_present(self):
9998
raw_setup = json.loads(
10099
(FIXTURES_DIR / "setup_tahoma_1.json").read_text(encoding="utf-8")
101100
)
102-
setup = converter.structure(decamelize(raw_setup), Setup)
101+
setup = structure_response(raw_setup, Setup)
103102
raw_id = "SETUP-1234-1234-8044"
104103
redacted_id = obfuscate_id(raw_id)
105104

@@ -112,7 +111,7 @@ def test_id_is_none_when_missing(self):
112111
raw_setup = json.loads(
113112
(FIXTURES_DIR / "setup_local.json").read_text(encoding="utf-8")
114113
)
115-
setup = converter.structure(decamelize(raw_setup), Setup)
114+
setup = structure_response(raw_setup, Setup)
116115

117116
assert setup.id is None
118117

@@ -838,7 +837,7 @@ class TestEvent:
838837

839838
def test_execution_state_changed_event(self):
840839
"""Optional[Enum] fields (old_state, new_state) are structured into enums."""
841-
raw = decamelize(
840+
event = structure_response(
842841
{
843842
"timestamp": 1631130760744,
844843
"setupOID": "741bc89f-a47b-4ad6-894d-a785c06956c2",
@@ -850,9 +849,9 @@ def test_execution_state_changed_event(self):
850849
"oldState": "TRANSMITTED",
851850
"timeToNextState": 0,
852851
"name": "ExecutionStateChangedEvent",
853-
}
852+
},
853+
Event,
854854
)
855-
event = converter.structure(raw, Event)
856855

857856
assert event.name == EventName.EXECUTION_STATE_CHANGED
858857
assert event.old_state is ExecutionState.TRANSMITTED
@@ -861,36 +860,36 @@ def test_execution_state_changed_event(self):
861860

862861
def test_failure_type_code_structured_as_enum(self):
863862
"""FailureType | None field is structured into an enum instance."""
864-
raw = decamelize(
863+
event = structure_response(
865864
{
866865
"name": "ExecutionStateChangedEvent",
867866
"timestamp": 123,
868867
"failureTypeCode": 0,
869-
}
868+
},
869+
Event,
870870
)
871-
event = converter.structure(raw, Event)
872871

873872
assert isinstance(event.failure_type_code, FailureType)
874873
assert event.failure_type_code is FailureType.NO_FAILURE
875874

876875
def test_optional_enum_fields_none_when_absent(self):
877876
"""Optional enum fields default to None when not present in the payload."""
878-
raw = decamelize(
877+
event = structure_response(
879878
{
880879
"name": "GatewaySynchronizationEndedEvent",
881880
"timestamp": 1631130645998,
882881
"gatewayId": "9876-1234-8767",
883-
}
882+
},
883+
Event,
884884
)
885-
event = converter.structure(raw, Event)
886885

887886
assert event.old_state is None
888887
assert event.new_state is None
889888
assert event.failure_type_code is None
890889

891890
def test_device_state_changed_event_with_states(self):
892891
"""DeviceStateChangedEvent payload structures device_states as EventState."""
893-
raw = decamelize(
892+
event = structure_response(
894893
{
895894
"timestamp": 1631130646544,
896895
"setupOID": "741bc89f-a47b-4ad6-894d-a785c06956c2",
@@ -903,9 +902,9 @@ def test_device_state_changed_event_with_states(self):
903902
}
904903
],
905904
"name": "DeviceStateChangedEvent",
906-
}
905+
},
906+
Event,
907907
)
908-
event = converter.structure(raw, Event)
909908

910909
assert event.name == EventName.DEVICE_STATE_CHANGED
911910
assert len(event.device_states) == 1
@@ -914,7 +913,7 @@ def test_device_state_changed_event_with_states(self):
914913
def test_event_fixture_structures_all_events(self):
915914
"""All events in the cloud fixture file structure without errors."""
916915
raw_events = json.loads((Path("tests/fixtures/event/events.json")).read_text())
917-
events = [converter.structure(decamelize(e), Event) for e in raw_events]
916+
events = [structure_response(e, Event) for e in raw_events]
918917

919918
assert len(events) == len(raw_events)
920919
state_changed = [

0 commit comments

Comments
 (0)