Add ruff (lint + format) and ty (type checking) to the project
Adds [tool.ruff] and [tool.ty] config to pyproject.toml with a curated ruff rule set (E, F, W, I, UP, B, C4, SIM, RUF, ASYNC, LOG, G, PIE, RET, PERF, N), pins ruff/ty in requirements-dev.txt, reformats the whole tree with `ruff format`, and fixes the pre-existing lint and type-check debt those tools surfaced so both run clean. Production-code type fixes include: HA's ConfigFlowResult vs. the generic FlowResult in config_flow.py, narrowing BoundEntity.desc to its platform-specific subclass (SelectDesc/NumberDesc/SensorDesc/etc.) via cast() instead of an unchecked annotation, converting HA device_class strings to their proper enum types, a resolve_registry callback typed as `object` instead of `DeviceRegistry | None`, and a couple of other narrow correctness fixes (CA key type validation, an index-out-of-bounds false positive from an empty-tuple fallback, a bool/dict argument swap). Test-file fixes are mechanical: narrowing SamsungEntityDescription to the correct subclass via isinstance()/cast() before accessing subclass-only fields, and asserting Optional write_fn/unit_fn fields are set before calling them.
This commit is contained in:
147 files changed
+9977
-7584
No files matched your search
@@ -11,6 +11,7 @@ real poll lands. async_send_command must pre-merge the token into the
|
||||
cached array itself before applying it optimistically, while still POSTing
|
||||
only the minimal body over the wire.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
@@ -21,7 +22,11 @@ from homeassistant.core import HomeAssistant
|
||||
from pytest_homeassistant_custom_component.common import MockConfigEntry
|
||||
|
||||
from custom_components.localthings.const import (
|
||||
CONF_HOST, CONF_LEAF_CERT_PEM, CONF_LEAF_KEY_PEM, CONF_PORT, DOMAIN,
|
||||
CONF_HOST,
|
||||
CONF_LEAF_CERT_PEM,
|
||||
CONF_LEAF_KEY_PEM,
|
||||
CONF_PORT,
|
||||
DOMAIN,
|
||||
)
|
||||
from custom_components.localthings.coordinator import LocalThingsCoordinator
|
||||
from custom_components.localthings.registry.capabilities import laundry
|
||||
@@ -31,10 +36,10 @@ from custom_components.localthings.registry.entities import ClimateDesc
|
||||
from custom_components.localthings.registry.subdevices import Subdevice
|
||||
|
||||
ENTRY_DATA = {
|
||||
CONF_HOST: '10.0.0.199',
|
||||
CONF_HOST: "10.0.0.199",
|
||||
CONF_PORT: 49154,
|
||||
CONF_LEAF_CERT_PEM: '-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----',
|
||||
CONF_LEAF_KEY_PEM: '-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----',
|
||||
CONF_LEAF_CERT_PEM: "-----BEGIN CERTIFICATE-----\nTEST-LEAF\n-----END CERTIFICATE-----",
|
||||
CONF_LEAF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nTEST-LEAF-KEY\n-----END PRIVATE KEY-----",
|
||||
}
|
||||
|
||||
|
||||
@@ -44,7 +49,7 @@ class _FakeSendSession:
|
||||
|
||||
def post(self, path_segs, payload, timeout=None):
|
||||
self.post_calls.append((list(path_segs), payload))
|
||||
return 0x44, b''
|
||||
return 0x44, b""
|
||||
|
||||
def pace(self):
|
||||
pass
|
||||
@@ -53,7 +58,9 @@ class _FakeSendSession:
|
||||
@pytest.fixture
|
||||
def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN, data=ENTRY_DATA, unique_id='localthings_SENDCMD-TEST',
|
||||
domain=DOMAIN,
|
||||
data=ENTRY_DATA,
|
||||
unique_id="localthings_SENDCMD-TEST",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
coord = LocalThingsCoordinator(hass, entry)
|
||||
@@ -63,37 +70,47 @@ def coordinator(hass: HomeAssistant) -> LocalThingsCoordinator:
|
||||
|
||||
|
||||
async def test_options_write_posts_only_the_changed_token(coordinator) -> None:
|
||||
href = '/course/vs/0'
|
||||
coordinator._observe.apply(href, {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
|
||||
}, source='poll')
|
||||
href = "/course/vs/0"
|
||||
coordinator._observe.apply(
|
||||
href,
|
||||
{
|
||||
"x.com.samsung.da.options": ["DeviceType_0167", "Course_16", "GMT_04"],
|
||||
},
|
||||
source="poll",
|
||||
)
|
||||
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
|
||||
bound = BoundEntity(href=href, capability=None, desc=desc)
|
||||
|
||||
await coordinator.async_send_command(bound, '1D')
|
||||
await coordinator.async_send_command(bound, "1D")
|
||||
|
||||
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
||||
assert posted_path == ['course', 'vs', '0']
|
||||
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.options': ['Course_1D']}
|
||||
assert posted_path == ["course", "vs", "0"]
|
||||
assert cbor2.loads(posted_bytes) == {"x.com.samsung.da.options": ["Course_1D"]}
|
||||
|
||||
|
||||
async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator) -> None:
|
||||
"""The regression this guards: applying the minimal wire body straight
|
||||
to the cache would wipe DeviceType_0167/GMT_04 until the next poll."""
|
||||
href = '/course/vs/0'
|
||||
coordinator._observe.apply(href, {
|
||||
'x.com.samsung.da.options': ['DeviceType_0167', 'Course_16', 'GMT_04'],
|
||||
}, source='poll')
|
||||
href = "/course/vs/0"
|
||||
coordinator._observe.apply(
|
||||
href,
|
||||
{
|
||||
"x.com.samsung.da.options": ["DeviceType_0167", "Course_16", "GMT_04"],
|
||||
},
|
||||
source="poll",
|
||||
)
|
||||
|
||||
desc = laundry.cycle_select(translation_key='dryer_cycle', icon='x')
|
||||
desc = laundry.cycle_select(translation_key="dryer_cycle", icon="x")
|
||||
bound = BoundEntity(href=href, capability=None, desc=desc)
|
||||
|
||||
await coordinator.async_send_command(bound, '1D')
|
||||
await coordinator.async_send_command(bound, "1D")
|
||||
|
||||
cached = coordinator._cache.get(href)
|
||||
assert cached['x.com.samsung.da.options'] == [
|
||||
'DeviceType_0167', 'Course_1D', 'GMT_04',
|
||||
assert cached["x.com.samsung.da.options"] == [
|
||||
"DeviceType_0167",
|
||||
"Course_1D",
|
||||
"GMT_04",
|
||||
]
|
||||
|
||||
|
||||
@@ -107,63 +124,65 @@ async def test_options_write_optimistic_cache_keeps_sibling_tokens(coordinator)
|
||||
# its own.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _climate_bound(href: str, subdevice: Subdevice) -> BoundEntity:
|
||||
desc = ClimateDesc(key='climate', translation_key='airconditioner',
|
||||
write_fn=_climate_write)
|
||||
desc = ClimateDesc(key="climate", translation_key="airconditioner", write_fn=_climate_write)
|
||||
return BoundEntity(href=href, capability=None, desc=desc, subdevice=subdevice)
|
||||
|
||||
|
||||
async def test_indexed_subdevice_write_posts_to_translated_path(coordinator) -> None:
|
||||
"""A power write from the bedroom subdevice's (indexed '1') climate entity
|
||||
must POST to /power/vs/1, not the master's /power/vs/0."""
|
||||
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
bound = _climate_bound('/mode/vs/1', sub1)
|
||||
sub1 = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
bound = _climate_bound("/mode/vs/1", sub1)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
await coordinator.async_send_command(bound, ("power", True))
|
||||
|
||||
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
||||
assert posted_path == ['power', 'vs', '1']
|
||||
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
|
||||
assert posted_path == ["power", "vs", "1"]
|
||||
assert cbor2.loads(posted_bytes) == {"x.com.samsung.da.power": "On"}
|
||||
|
||||
|
||||
async def test_indexed_subdevice_write_applies_optimistic_value_to_translated_href(
|
||||
coordinator,
|
||||
) -> None:
|
||||
sub1 = Subdevice(kind='indexed', key='1', seed_path=('device', '1'))
|
||||
bound = _climate_bound('/mode/vs/1', sub1)
|
||||
sub1 = Subdevice(kind="indexed", key="1", seed_path=("device", "1"))
|
||||
bound = _climate_bound("/mode/vs/1", sub1)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
await coordinator.async_send_command(bound, ("power", True))
|
||||
|
||||
# The optimistic apply + settle guard must land on /power/vs/1 -- the
|
||||
# translated href a real device confirms this write against -- not on
|
||||
# /mode/vs/1 (bound_entity.href) or /power/vs/0 (the master's resource).
|
||||
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
|
||||
assert coordinator._cache.get('/power/vs/0') is None
|
||||
assert coordinator._cache.get("/power/vs/1") == {"x.com.samsung.da.power": "On"}
|
||||
assert coordinator._cache.get("/power/vs/0") is None
|
||||
# The settle guard is armed on that same translated href: a stale poll
|
||||
# reporting the pre-write value must be dropped, not allowed to revert
|
||||
# the optimistic 'On' (issue #27's regression, translated to a
|
||||
# subdevice's own resource).
|
||||
applied = coordinator._observe.apply(
|
||||
'/power/vs/1', {'x.com.samsung.da.power': 'Off'}, source='poll',
|
||||
"/power/vs/1",
|
||||
{"x.com.samsung.da.power": "Off"},
|
||||
source="poll",
|
||||
)
|
||||
assert applied is False
|
||||
assert coordinator._cache.get('/power/vs/1') == {'x.com.samsung.da.power': 'On'}
|
||||
assert coordinator._cache.get("/power/vs/1") == {"x.com.samsung.da.power": "On"}
|
||||
|
||||
|
||||
async def test_prefixed_subdevice_write_posts_to_translated_path(coordinator) -> None:
|
||||
"""A power write from a UUID-prefixed subdevice's climate entity must POST
|
||||
to /<uuid>/power/vs/0, not the bare canonical href."""
|
||||
sub_id = '6c2dff6d-ee5c-dad1-6a5e-000000000001'
|
||||
subdevice = Subdevice(kind='prefixed', key=sub_id, seed_path=(sub_id, 'device', '0'))
|
||||
bound = _climate_bound(f'/{sub_id}/mode/vs/0', subdevice)
|
||||
sub_id = "6c2dff6d-ee5c-dad1-6a5e-000000000001"
|
||||
subdevice = Subdevice(kind="prefixed", key=sub_id, seed_path=(sub_id, "device", "0"))
|
||||
bound = _climate_bound(f"/{sub_id}/mode/vs/0", subdevice)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
await coordinator.async_send_command(bound, ("power", True))
|
||||
|
||||
posted_path, posted_bytes = coordinator._session.post_calls[0]
|
||||
assert posted_path == [sub_id, 'power', 'vs', '0']
|
||||
assert cbor2.loads(posted_bytes) == {'x.com.samsung.da.power': 'On'}
|
||||
assert coordinator._cache.get(f'/{sub_id}/power/vs/0') == {
|
||||
'x.com.samsung.da.power': 'On',
|
||||
assert posted_path == [sub_id, "power", "vs", "0"]
|
||||
assert cbor2.loads(posted_bytes) == {"x.com.samsung.da.power": "On"}
|
||||
assert coordinator._cache.get(f"/{sub_id}/power/vs/0") == {
|
||||
"x.com.samsung.da.power": "On",
|
||||
}
|
||||
|
||||
|
||||
@@ -172,9 +191,10 @@ async def test_main_climate_write_unaffected_by_subdevice_translation(coordinato
|
||||
subdevices must keep posting to the exact same path as before this
|
||||
translation step existed."""
|
||||
from custom_components.localthings.registry.subdevices import MAIN
|
||||
bound = _climate_bound('/mode/vs/0', MAIN)
|
||||
|
||||
await coordinator.async_send_command(bound, ('power', True))
|
||||
bound = _climate_bound("/mode/vs/0", MAIN)
|
||||
|
||||
await coordinator.async_send_command(bound, ("power", True))
|
||||
|
||||
posted_path, _ = coordinator._session.post_calls[0]
|
||||
assert posted_path == ['power', 'vs', '0']
|
||||
assert posted_path == ["power", "vs", "0"]
|
||||
Reference in new issue
Block a user