Skip to content

Commit 843d481

Browse files
committed
Merge branch 'feature/get-nested-value-helper' into edge
2 parents f320ba8 + 903b49b commit 843d481

File tree

13 files changed

+481
-96
lines changed

13 files changed

+481
-96
lines changed

custom_components/alexa_media/__init__.py

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@
8787
_existing_serials,
8888
alarm_just_dismissed,
8989
calculate_uuid,
90+
safe_get,
9091
)
9192
from .notify import async_unload_entry as notify_async_unload_entry
9293
from .services import AlexaMediaServices
@@ -237,8 +238,8 @@ async def async_setup_entry(hass, config_entry):
237238
async def close_alexa_media(event=None) -> None:
238239
"""Clean up Alexa connections."""
239240
_LOGGER.debug("Received shutdown request: %s", event)
240-
if hass.data.get(DATA_ALEXAMEDIA, {}).get("accounts"):
241-
for email, _ in hass.data[DATA_ALEXAMEDIA]["accounts"].items():
241+
if accounts := safe_get(hass.data, [DATA_ALEXAMEDIA, "accounts"], {}):
242+
for email, _ in accounts.items():
242243
await close_connections(hass, email)
243244

244245
async def complete_startup(event=None) -> None:
@@ -695,9 +696,13 @@ async def async_update_data() -> Optional[AlexaEntityData]:
695696
.get(serial)
696697
.enabled
697698
):
698-
await hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][
699-
"media_player"
700-
].get(serial).refresh(device, skip_api=True)
699+
await (
700+
hass.data[DATA_ALEXAMEDIA]["accounts"][email]["entities"][
701+
"media_player"
702+
]
703+
.get(serial)
704+
.refresh(device, skip_api=True)
705+
)
701706
_LOGGER.debug(
702707
"%s: Existing: %s New: %s;"
703708
" Filtered out by not being in include: %s "
@@ -818,9 +823,7 @@ async def process_notifications(login_obj, raw_notifications=None) -> bool:
818823
notification["date_time"] = (
819824
f"{n_date} {n_time}" if n_date and n_time else None
820825
)
821-
previous_alarm = (
822-
previous.get(n_dev_id, {}).get("Alarm", {}).get(n_id)
823-
)
826+
previous_alarm = safe_get(previous, [n_dev_id, "Alarm", n_id], {})
824827
if previous_alarm and alarm_just_dismissed(
825828
notification,
826829
previous_alarm.get("status"),
@@ -1577,7 +1580,7 @@ async def async_unload_entry(hass, entry) -> bool:
15771580
else:
15781581
_LOGGER.debug("Forwarding unload entry to %s", component)
15791582
await hass.config_entries.async_forward_entry_unload(entry, component)
1580-
except Exception as ex:
1583+
except Exception:
15811584
_LOGGER.error("Error unloading: %s", component)
15821585
await close_connections(hass, email)
15831586
for listener in hass.data[DATA_ALEXAMEDIA]["accounts"][email][DATA_LISTENER]:

custom_components/alexa_media/alarm_control_panel.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
DEFAULT_QUEUE_DELAY,
2828
DOMAIN as ALEXA_DOMAIN,
2929
)
30-
from .helpers import _catch_login_errors, add_devices
30+
from .helpers import _catch_login_errors, add_devices, safe_get
3131

3232
try:
3333
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
@@ -45,12 +45,12 @@ async def async_setup_platform(
4545
hass, config, add_devices_callback, discovery_info=None
4646
) -> bool:
4747
"""Set up the Alexa alarm control panel platform."""
48-
devices = [] # type: List[AlexaAlarmControlPanel]
48+
devices: list[AlexaAlarmControlPanel] = []
4949
account = None
5050
if config:
5151
account = config.get(CONF_EMAIL)
5252
if account is None and discovery_info:
53-
account = discovery_info.get("config", {}).get(CONF_EMAIL)
53+
account = safe_get(discovery_info, ["config", CONF_EMAIL])
5454
if account is None:
5555
raise ConfigEntryNotReady
5656
include_filter = config.get(CONF_INCLUDE_DEVICES, [])
@@ -74,7 +74,7 @@ async def async_setup_platform(
7474
]
7575
) = {}
7676
alexa_client: Optional[AlexaAlarmControlPanel] = None
77-
guard_entities = account_dict.get("devices", {}).get("guard", [])
77+
guard_entities = safe_get(account_dict, ["devices", "guard"], [])
7878
if guard_entities:
7979
alexa_client = AlexaAlarmControlPanel(
8080
account_dict["login_obj"],
@@ -156,7 +156,9 @@ def __init__(self, login, coordinator, guard_entity, media_players=None) -> None
156156

157157
@_catch_login_errors
158158
async def _async_alarm_set(
159-
self, command: str = "", code=None # pylint: disable=unused-argument
159+
self,
160+
command: str = "",
161+
code=None, # pylint: disable=unused-argument
160162
) -> None:
161163
"""Send command."""
162164
try:
@@ -190,13 +192,15 @@ async def _async_alarm_set(
190192
await self.coordinator.async_request_refresh()
191193

192194
async def async_alarm_disarm(
193-
self, code=None # pylint:disable=unused-argument
195+
self,
196+
code=None, # pylint:disable=unused-argument
194197
) -> None:
195198
"""Send disarm command."""
196199
await self._async_alarm_set(STATE_ALARM_DISARMED)
197200

198201
async def async_alarm_arm_away(
199-
self, code=None # pylint:disable=unused-argument
202+
self,
203+
code=None, # pylint:disable=unused-argument
200204
) -> None:
201205
"""Send arm away command."""
202206
await self._async_alarm_set(STATE_ALARM_ARMED_AWAY)

custom_components/alexa_media/alexa_entity.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
from alexapy import AlexaAPI, AlexaLogin, hide_serial
1717
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
1818

19+
from custom_components.alexa_media.helpers import safe_get
20+
1921
_LOGGER = logging.getLogger(__name__)
2022

2123

@@ -53,7 +55,7 @@ def is_hue_v1(appliance: dict[str, Any]) -> bool:
5355

5456

5557
def is_skill(appliance: dict[str, Any]) -> bool:
56-
namespace = appliance.get("driverIdentity", {}).get("namespace", "")
58+
namespace = safe_get(appliance, ["driverIdentity", "namespace"], "")
5759
return namespace and namespace == "SKILL"
5860

5961

@@ -387,7 +389,7 @@ async def get_entity_data(
387389
device_states = raw.get("deviceStates", []) if isinstance(raw, dict) else None
388390
if device_states:
389391
for device_state in device_states:
390-
entity_id = device_state.get("entity", {}).get("entityId")
392+
entity_id = safe_get(device_state, ["entity", "entityId"])
391393
if entity_id:
392394
entities[entity_id] = []
393395
cap_states = device_state.get("capabilityStates", [])

custom_components/alexa_media/binary_sensor.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
)
2727
from .alexa_entity import parse_detection_state_from_coordinator
2828
from .const import CONF_EXTENDED_ENTITY_DISCOVERY
29-
from .helpers import add_devices
29+
from .helpers import add_devices, safe_get
3030

3131
_LOGGER = logging.getLogger(__name__)
3232

@@ -38,15 +38,15 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf
3838
if config:
3939
account = config.get(CONF_EMAIL)
4040
if account is None and discovery_info:
41-
account = discovery_info.get("config", {}).get(CONF_EMAIL)
41+
account = safe_get(discovery_info, ["config", CONF_EMAIL])
4242
if account is None:
4343
raise ConfigEntryNotReady
4444
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
4545
include_filter = config.get(CONF_INCLUDE_DEVICES, [])
4646
exclude_filter = config.get(CONF_EXCLUDE_DEVICES, [])
4747
coordinator = account_dict["coordinator"]
48-
binary_entities = account_dict.get("devices", {}).get("binary_sensor", [])
49-
if binary_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY):
48+
binary_entities = safe_get(account_dict, ["devices", "binary_sensor"], [])
49+
if account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY):
5050
for binary_entity in binary_entities:
5151
_LOGGER.debug(
5252
"Creating entity %s for a binary_sensor with name %s",

custom_components/alexa_media/helpers.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,23 +8,25 @@
88
"""
99

1010
import asyncio
11-
from collections.abc import Mapping, Sequence
1211
import functools
1312
import hashlib
1413
import logging
15-
from typing import Any, Callable, Optional
14+
from typing import Any, Callable, Optional, TypeVar, overload
1615

16+
import wrapt
1717
from alexapy import AlexapyLoginCloseRequested, AlexapyLoginError, hide_email
1818
from alexapy.alexalogin import AlexaLogin
19+
from dictor import dictor
1920
from homeassistant.const import CONF_EMAIL, CONF_URL
21+
from homeassistant.core import HomeAssistant
2022
from homeassistant.exceptions import ConditionErrorMessage
2123
from homeassistant.helpers.entity_component import EntityComponent
2224
from homeassistant.helpers.instance_id import async_get as async_get_instance_id
23-
import wrapt
2425

2526
from .const import DATA_ALEXAMEDIA, EXCEPTION_TEMPLATE
2627

2728
_LOGGER = logging.getLogger(__name__)
29+
ArgType = TypeVar("ArgType")
2830

2931

3032
async def add_devices(
@@ -336,3 +338,84 @@ def alarm_just_dismissed(
336338
# We also know the alarm's status rules out a snooze.
337339
# The only remaining possibility is that this alarm was just dismissed.
338340
return True
341+
342+
343+
def is_http2_enabled(hass: HomeAssistant | None, login_email: str) -> bool:
344+
"""Whether HTTP2 push is enabled for the current account session"""
345+
if hass:
346+
return bool(
347+
safe_get(
348+
hass.data,
349+
[DATA_ALEXAMEDIA, "accounts", login_email, "http2"],
350+
)
351+
)
352+
return False
353+
354+
355+
@overload
356+
def safe_get(
357+
data: Any,
358+
path_list: list[str | int] | None = None,
359+
checknone: bool = False,
360+
ignorecase: bool = False,
361+
pathsep: str = ".",
362+
search: Any = None,
363+
pretty: bool = False,
364+
rtype: str | None = None,
365+
) -> Any | None: ...
366+
367+
368+
@overload
369+
def safe_get(
370+
data: Any, path_list: list[str | int] | None, default: ArgType, *args, **kwargs
371+
) -> ArgType: ...
372+
373+
374+
def safe_get(
375+
data: Any, path_list: list[str | int] | None = None, *args, **kwargs
376+
) -> None | Any:
377+
"""Safely get nested value using path segments with optional type checking.
378+
379+
Args:
380+
data: Source data structure
381+
path_list: List of path segments (dots in segment names are auto-escaped)
382+
*args: Positional arguments passed to dictor (e.g., default value)
383+
**kwargs: Keyword arguments passed to dictor (checknone, ignorecase)
384+
385+
Returns:
386+
The value at the specified path, or None if:
387+
- The path doesn't exist and no default is provided
388+
or default if:
389+
- A default is provided and the path doesn't exist
390+
- A default is provided and the retrieved value's type doesn't match the default's type
391+
392+
Note:
393+
- Do not pass 'pathsep' in kwargs as the path is pre-built.
394+
- Type checking: When a default value is provided and a non-None value is retrieved,
395+
the result is validated against the default's type. If types don't match, default is returned.
396+
This prevents silent type errors from malformed data structures.
397+
398+
Examples:
399+
>>> safe_get({"a": {"b": "value"}}, ["a", "b"])
400+
'value'
401+
402+
>>> safe_get({"a": {"b": 123}}, ["a", "b"], "default")
403+
'default' # Type mismatch: int vs str
404+
405+
>>> safe_get({"a": {"b": "value"}}, ["a", "b"], "default")
406+
'value' # Type matches
407+
"""
408+
if not path_list:
409+
raise ValueError("path_list cannot be empty")
410+
411+
if "pathsep" in kwargs:
412+
kwargs.pop("pathsep") # Ignore pathsep since we build the path
413+
414+
escaped_segments = (str(seg).replace(".", "\\.") for seg in path_list)
415+
path = ".".join(escaped_segments)
416+
default = args[0] if args else (kwargs.get("default") if kwargs else None)
417+
result = dictor(data, path, *args, **kwargs)
418+
if default is not None and result is not None:
419+
if not isinstance(result, type(default)):
420+
result = default
421+
return result

custom_components/alexa_media/light.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
parse_power_from_coordinator,
4444
)
4545
from .const import CONF_EXTENDED_ENTITY_DISCOVERY
46-
from .helpers import add_devices
46+
from .helpers import add_devices, safe_get
4747

4848
_LOGGER = logging.getLogger(__name__)
4949

@@ -57,7 +57,7 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf
5757
if config:
5858
account = config.get(CONF_EMAIL)
5959
if account is None and discovery_info:
60-
account = discovery_info.get("config", {}).get(CONF_EMAIL)
60+
account = safe_get(discovery_info, ["config", CONF_EMAIL])
6161
if account is None:
6262
raise ConfigEntryNotReady
6363
account_dict = hass.data[DATA_ALEXAMEDIA]["accounts"][account]
@@ -67,8 +67,8 @@ async def async_setup_platform(hass, config, add_devices_callback, discovery_inf
6767
hue_emulated_enabled = "emulated_hue" in hass.config.as_dict().get(
6868
"components", set()
6969
)
70-
light_entities = account_dict.get("devices", {}).get("light", [])
71-
if light_entities and account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY):
70+
light_entities = safe_get(account_dict, ["devices", "light"], [])
71+
if account_dict["options"].get(CONF_EXTENDED_ENTITY_DISCOVERY):
7272
for light_entity in light_entities:
7373
if not (light_entity["is_hue_v1"] and hue_emulated_enabled):
7474
_LOGGER.debug(

0 commit comments

Comments
 (0)