Skip to content

Commit a6473d6

Browse files
authored
Eliminate recursive decamelize pre-pass (~43% faster structuring) (#2062)
## Summary - Replace the two-pass approach (recursive `decamelize()` key rename + cattrs structure) with single-pass cattrs rename hooks via `register_structure_hook_factory` + `make_dict_structure_fn(override(rename=...))` - Handle non-standard API casing (`deviceURL`, `placeOID`, `setupOID`) directly in `camelize_key` - Simplify `serializers.py` which no longer needs its own post-camelize fixup ## Benchmark Measured across all 25 setup fixtures + devices.json (median of 5 runs, 100 iterations each): | | Old (decamelize + structure) | New (single-pass) | Speedup | |---|---|---|---| | **Full pass (all fixtures)** | 16.6 ms | 9.6 ms | **1.7x (42% faster)** | Per-call for the largest fixtures: | Fixture | Old | New | Speedup | |---|---|---|---| | `setup_tahoma_daikin.json` | 2.23 ms | 1.36 ms | 1.6x | | `setup_3_gateways.json` | 1.66 ms | 0.84 ms | 2.0x | | `setup_cozytouch_4.json` | 1.28 ms | 0.72 ms | 1.8x | | `setup_hue_and_low_speed.json` | 1.25 ms | 0.73 ms | 1.7x | | `setup_local_with_climate.json` | 1.26 ms | 0.57 ms | 2.2x | ## Breaking changes - `structure_response()` no longer calls `decamelize()` — it expects camelCase input directly (which is what the API sends) - `camelize_key()` now returns `deviceURL`/`placeOID`/`setupOID` for those fields (previously required a post-fixup in serializers) - `decamelize()` / `recursive_key_map()` remain available in `pyoverkiz._case` but are no longer on the hot path ## Test plan - [x] All 438 existing tests pass - [x] mypy + ty type checking pass - [x] ruff lint + format pass
1 parent 79ccc39 commit a6473d6

8 files changed

Lines changed: 97 additions & 180 deletions

File tree

pyoverkiz/_case.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,9 @@
33
from __future__ import annotations
44

55
import functools
6-
import re
76
from collections.abc import Callable
87
from typing import Any
98

10-
_CAMEL_RE = re.compile(r"([A-Z]+)([A-Z][a-z])|([a-z\d])([A-Z])")
11-
12-
13-
@functools.lru_cache(maxsize=1024)
14-
def _decamelize_key(key: str) -> str:
15-
"""Convert a single camelCase key to snake_case."""
16-
result = _CAMEL_RE.sub(r"\1\3_\2\4", key)
17-
return result.lower()
18-
199

2010
def recursive_key_map(data: Any, key_fn: Callable[[str], str]) -> Any:
2111
"""Recursively apply *key_fn* to every dict key in *data*."""
@@ -26,13 +16,20 @@ def recursive_key_map(data: Any, key_fn: Callable[[str], str]) -> Any:
2616
return data
2717

2818

29-
def decamelize(data: Any) -> Any:
30-
"""Recursively convert dict keys from camelCase to snake_case."""
31-
return recursive_key_map(data, _decamelize_key)
19+
_CAMELIZE_OVERRIDES: dict[str, str] = {
20+
"device_url": "deviceURL",
21+
"place_oid": "placeOID",
22+
"setup_oid": "setupOID",
23+
}
3224

3325

3426
@functools.lru_cache(maxsize=1024)
3527
def camelize_key(key: str) -> str:
36-
"""Convert a single snake_case key to camelCase."""
28+
"""Convert a single snake_case key to camelCase.
29+
30+
Handles non-standard API casing (e.g. deviceURL, placeOID) via overrides.
31+
"""
32+
if key in _CAMELIZE_OVERRIDES:
33+
return _CAMELIZE_OVERRIDES[key]
3734
parts = key.split("_")
3835
return parts[0] + "".join(word.capitalize() for word in parts[1:])

pyoverkiz/client.py

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
from pyoverkiz.action_queue import ActionQueue, ActionQueueSettings
2525
from pyoverkiz.auth import AuthStrategy, Credentials, build_auth_strategy
2626
from pyoverkiz.const import SUPPORTED_SERVERS, USER_AGENT
27-
from pyoverkiz.converter import converter, structure_response
27+
from pyoverkiz.converter import converter
2828
from pyoverkiz.enums import APIType, ExecutionMode, Protocol, Server
2929
from pyoverkiz.exceptions import (
3030
ExecutionQueueFullError,
@@ -333,7 +333,7 @@ async def get_setup(self, refresh: bool = False) -> Setup:
333333

334334
response = await self._get("setup")
335335

336-
setup = structure_response(response, Setup)
336+
setup = converter.structure(response, Setup)
337337

338338
# Cache response
339339
self.setup = setup
@@ -381,7 +381,7 @@ async def get_devices(self, refresh: bool = False) -> list[Device]:
381381
return self.devices
382382

383383
response = await self._get("setup/devices")
384-
devices = structure_response(response, list[Device])
384+
devices = converter.structure(response, list[Device])
385385

386386
# Cache response
387387
self.devices = devices
@@ -400,7 +400,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]:
400400
return self.gateways
401401

402402
response = await self._get("setup/gateways")
403-
gateways = structure_response(response, list[Gateway])
403+
gateways = converter.structure(response, list[Gateway])
404404

405405
# Cache response
406406
self.gateways = gateways
@@ -413,7 +413,7 @@ async def get_gateways(self, refresh: bool = False) -> list[Gateway]:
413413
async def get_execution_history(self) -> list[HistoryExecution]:
414414
"""List past executions and their outcomes."""
415415
response = await self._get("history/executions")
416-
return structure_response(response, list[HistoryExecution])
416+
return converter.structure(response, list[HistoryExecution])
417417

418418
@retry_on_auth_error
419419
async def get_device_definition(self, device_url: str) -> Definition | None:
@@ -426,15 +426,15 @@ async def get_device_definition(self, device_url: str) -> Definition | None:
426426
if raw is None:
427427
return None
428428

429-
return structure_response(raw, Definition)
429+
return converter.structure(raw, Definition)
430430

431431
@retry_on_auth_error
432432
async def get_state(self, device_url: str) -> list[State]:
433433
"""Retrieve states of requested device."""
434434
response = await self._get(
435435
f"setup/devices/{urllib.parse.quote_plus(device_url)}/states"
436436
)
437-
return structure_response(response, list[State])
437+
return converter.structure(response, list[State])
438438

439439
@retry_on_auth_error
440440
async def refresh_states(self) -> None:
@@ -477,7 +477,7 @@ async def fetch_events(self) -> list[Event]:
477477
operation (polling).
478478
"""
479479
response = await self._post(f"events/{self.event_listener_id}/fetch")
480-
return structure_response(response, list[Event])
480+
return converter.structure(response, list[Event])
481481

482482
async def unregister_event_listener(self) -> None:
483483
"""Unregister an event listener.
@@ -497,13 +497,13 @@ async def get_current_execution(self, exec_id: str) -> Execution | None:
497497
if not response or not isinstance(response, dict):
498498
return None
499499

500-
return structure_response(response, Execution)
500+
return converter.structure(response, Execution)
501501

502502
@retry_on_auth_error
503503
async def get_current_executions(self) -> list[Execution]:
504504
"""Get all currently running executions."""
505505
response = await self._get("exec/current")
506-
return structure_response(response, list[Execution])
506+
return converter.structure(response, list[Execution])
507507

508508
@retry_on_auth_error
509509
async def get_api_version(self) -> str:
@@ -641,7 +641,7 @@ async def cancel_execution(self, exec_id: str) -> None:
641641
async def get_action_groups(self) -> list[PersistedActionGroup]:
642642
"""List action groups persisted on the server."""
643643
response = await self._get("actionGroups")
644-
return structure_response(response, list[PersistedActionGroup])
644+
return converter.structure(response, list[PersistedActionGroup])
645645

646646
@retry_on_auth_error
647647
async def get_places(self) -> Place:
@@ -656,7 +656,7 @@ async def get_places(self) -> Place:
656656
- `sub_places`: List of nested places within this location
657657
"""
658658
response = await self._get("setup/places")
659-
return structure_response(response, Place)
659+
return converter.structure(response, Place)
660660

661661
@retry_on_auth_error
662662
async def execute_persisted_action_group(self, oid: str) -> str:
@@ -678,7 +678,7 @@ async def get_setup_options(self) -> list[Option]:
678678
Access scope : Full enduser API access (enduser/*).
679679
"""
680680
response = await self._get("setup/options")
681-
return structure_response(response, list[Option])
681+
return converter.structure(response, list[Option])
682682

683683
@retry_on_auth_error
684684
async def get_setup_option(self, option: str) -> Option | None:
@@ -689,7 +689,7 @@ async def get_setup_option(self, option: str) -> Option | None:
689689
response = await self._get(f"setup/options/{option}")
690690

691691
if response:
692-
return structure_response(response, Option)
692+
return converter.structure(response, Option)
693693

694694
return None
695695

@@ -707,7 +707,7 @@ async def get_setup_option_parameter(
707707
response = await self._get(f"setup/options/{option}/{parameter}")
708708

709709
if response:
710-
return structure_response(response, OptionParameter)
710+
return converter.structure(response, OptionParameter)
711711

712712
return None
713713

@@ -745,7 +745,7 @@ async def search_reference_devices(
745745
}
746746
"""
747747
response = await self._post("reference/devices/search", payload)
748-
return structure_response(response, DeviceSearchResult)
748+
return converter.structure(response, DeviceSearchResult)
749749

750750
@retry_on_auth_error
751751
async def get_reference_protocol_types(self) -> list[ProtocolType]:
@@ -758,7 +758,6 @@ async def get_reference_protocol_types(self) -> list[ProtocolType]:
758758
- label: Human-readable protocol label
759759
"""
760760
response = await self._get("reference/protocolTypes")
761-
# No decamelize — ProtocolType fields are all single-word lowercase already.
762761
return converter.structure(response, list[ProtocolType])
763762

764763
@retry_on_auth_error
@@ -789,7 +788,7 @@ async def get_reference_ui_profile(self, profile_name: str) -> UIProfileDefiniti
789788
response = await self._get(
790789
f"reference/ui/profile/{urllib.parse.quote_plus(profile_name)}"
791790
)
792-
return structure_response(response, UIProfileDefinition)
791+
return converter.structure(response, UIProfileDefinition)
793792

794793
@retry_on_auth_error
795794
async def get_reference_ui_profile_names(self) -> list[str]:
@@ -805,7 +804,7 @@ async def get_reference_ui_widgets(self) -> list[str]:
805804
async def get_devices_not_up_to_date(self) -> list[Device]:
806805
"""Get all devices whose firmware is not up to date."""
807806
response = await self._get("setup/devices/notUpToDate")
808-
return structure_response(response, list[Device])
807+
return converter.structure(response, list[Device])
809808

810809
@retry_on_auth_error
811810
async def get_device_firmware_status(
@@ -821,7 +820,7 @@ async def get_device_firmware_status(
821820
)
822821
except UnsupportedOperationError:
823822
return None
824-
return structure_response(response, FirmwareStatus)
823+
return converter.structure(response, FirmwareStatus)
825824

826825
@retry_on_auth_error
827826
async def get_device_firmware_update_capability(self, device_url: str) -> bool:
@@ -868,7 +867,7 @@ async def get_device_controllable_definition(
868867
)
869868
if response is None:
870869
return None
871-
return structure_response(response, Definition)
870+
return converter.structure(response, Definition)
872871

873872
@retry_on_auth_error
874873
async def get_device_alternative_controllables(self, device_url: str) -> list[str]:
@@ -885,7 +884,7 @@ async def get_device_manufacturer_references(
885884
response = await self._get(
886885
f"setup/devices/{urllib.parse.quote_plus(device_url)}/manufacturerReferences"
887886
)
888-
return structure_response(response, list[DeviceManufacturerReference])
887+
return converter.structure(response, list[DeviceManufacturerReference])
889888

890889
async def _get(self, path: str) -> Any:
891890
"""Make a GET request to the OverKiz API."""

pyoverkiz/converter.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@
88

99
import attr
1010
import cattrs
11+
from cattrs.gen import make_dict_structure_fn, override
1112

12-
from pyoverkiz._case import decamelize
13+
from pyoverkiz._case import camelize_key
1314
from pyoverkiz.models import (
1415
CommandDefinition,
1516
CommandDefinitions,
@@ -32,10 +33,23 @@ def _is_primitive_union(t: Any) -> bool:
3233
non_none = [arg for arg in get_args(t) if arg is not type(None)]
3334
if any(isinstance(arg, type) and attr.has(arg) for arg in non_none):
3435
return False
35-
# Exclude pure Optional[Enum] unions — those need the Enum structure hook.
3636
return not all(isinstance(arg, type) and issubclass(arg, Enum) for arg in non_none)
3737

3838

39+
def _rename_hook_factory(cls: type, converter: cattrs.Converter) -> Any:
40+
"""Generate a structuring hook that maps camelCase API keys to snake_case fields."""
41+
overrides = {}
42+
for f in attr.fields(cls):
43+
if not f.init or f.name.startswith("_"):
44+
continue
45+
if f.alias and f.alias != f.name:
46+
continue
47+
api_key = camelize_key(f.name)
48+
if api_key != f.name:
49+
overrides[f.name] = override(rename=api_key)
50+
return make_dict_structure_fn(cls, converter, **overrides) # type: ignore[arg-type]
51+
52+
3953
def _make_converter() -> cattrs.Converter:
4054
# Converter (not GenConverter) so unknown API keys are silently dropped for forward-compat.
4155
c = cattrs.Converter()
@@ -50,7 +64,7 @@ def _make_converter() -> cattrs.Converter:
5064
lambda v, t: v if isinstance(v, t) else t(v),
5165
)
5266

53-
# Custom container types that take a list in __init__
67+
# Custom container types that wrap a list in __init__
5468
def _structure_states(val: Any, _: type) -> States:
5569
if val is None:
5670
return States()
@@ -70,12 +84,15 @@ def _structure_state_definitions(val: Any, _: type) -> StateDefinitions:
7084
c.register_structure_hook(CommandDefinitions, _structure_command_definitions)
7185
c.register_structure_hook(StateDefinitions, _structure_state_definitions)
7286

87+
# For all other attrs classes: lazily generate a hook that renames camelCase
88+
# API keys to snake_case on first use. This avoids manual dependency ordering.
89+
skip = {States, CommandDefinitions, StateDefinitions}
90+
c.register_structure_hook_factory(
91+
lambda t: isinstance(t, type) and attr.has(t) and t not in skip,
92+
_rename_hook_factory,
93+
)
94+
7395
return c
7496

7597

7698
converter = _make_converter()
77-
78-
79-
def structure_response[T](data: Any, cls: type[T]) -> T:
80-
"""Decamelize an API response and structure it into the target type."""
81-
return converter.structure(decamelize(data), cls)

pyoverkiz/serializers.py

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
"""Helpers for preparing API payloads.
22
3-
This module centralizes JSON key formatting and any small transport-specific
4-
fixes (like mapping "deviceUrl" -> "deviceURL"). Models should produce
5-
logical snake_case payloads and the client should call `prepare_payload`
6-
before sending the payload to Overkiz.
3+
This module centralizes JSON key formatting for outgoing requests.
4+
Models produce logical snake_case payloads and the client calls
5+
`prepare_payload` before sending to Overkiz.
76
"""
87

98
from __future__ import annotations
@@ -12,23 +11,12 @@
1211

1312
from pyoverkiz._case import camelize_key, recursive_key_map
1413

15-
_ABBREV_MAP: dict[str, str] = {"deviceUrl": "deviceURL"}
16-
17-
18-
def _camelize_key(key: str) -> str:
19-
"""Camelize a single key and apply abbreviation fixes in one step."""
20-
camel = camelize_key(key)
21-
return _ABBREV_MAP.get(camel, camel)
22-
2314

2415
def prepare_payload(payload: Any) -> Any:
25-
"""Convert snake_case payload to API-ready camelCase and apply fixes.
26-
27-
Performs camelization and abbreviation fixes in a single recursive pass
28-
to avoid walking the structure twice.
16+
"""Convert snake_case payload to API-ready camelCase.
2917
3018
Example:
3119
payload = {"device_url": "x", "commands": [{"name": "close"}]}
3220
=> {"deviceURL": "x", "commands": [{"name": "close"}]}
3321
"""
34-
return recursive_key_map(payload, _camelize_key)
22+
return recursive_key_map(payload, camelize_key)

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ packages = [
1616
dependencies = [
1717
"aiohttp<4.0.0,>=3.10.3",
1818
"backoff<3.0,>=1.10.0",
19-
"attrs>=21.2",
19+
"attrs>=22.2",
2020
"cattrs>=23.2",
2121
]
2222

0 commit comments

Comments
 (0)