From 45cd99e34c582a4dfd022d56d754784b53fbc430 Mon Sep 17 00:00:00 2001 From: Andrzej Pomirski Date: Fri, 27 Mar 2026 01:00:42 +0100 Subject: [PATCH] Make model keyword-only in all Device subclass constructors The base Device.__init__ defines model as keyword-only (after *), but six subclasses had it as positional: FanP5, Fan1C, Huizuo, AirConditioningCompanion, AirConditioningCompanionMcn02, and Yeelight. This fixes them all to match the base class and adds a test_model_is_keyword_only test that inspects every Device subclass signature to prevent regressions. Addresses #1156 --- miio/integrations/dmaker/fan/fan.py | 3 ++- miio/integrations/dmaker/fan/fan_miot.py | 3 ++- miio/integrations/huayi/light/huizuo.py | 3 ++- .../acpartner/airconditioningcompanion.py | 3 ++- .../acpartner/airconditioningcompanionMCN.py | 3 ++- miio/integrations/yeelight/light/yeelight.py | 1 + miio/tests/test_device.py | 22 +++++++++++++++++++ 7 files changed, 33 insertions(+), 5 deletions(-) diff --git a/miio/integrations/dmaker/fan/fan.py b/miio/integrations/dmaker/fan/fan.py index 443796ec9..d06b631a6 100644 --- a/miio/integrations/dmaker/fan/fan.py +++ b/miio/integrations/dmaker/fan/fan.py @@ -112,7 +112,8 @@ def __init__( debug: int = 0, lazy_discover: bool = True, timeout: Optional[int] = None, - model: str = MODEL_FAN_P5, + *, + model: Optional[str] = MODEL_FAN_P5, ) -> None: super().__init__( ip, token, start_id, debug, lazy_discover, timeout=timeout, model=model diff --git a/miio/integrations/dmaker/fan/fan_miot.py b/miio/integrations/dmaker/fan/fan_miot.py index 33a072ffa..154779161 100644 --- a/miio/integrations/dmaker/fan/fan_miot.py +++ b/miio/integrations/dmaker/fan/fan_miot.py @@ -467,7 +467,8 @@ def __init__( debug: int = 0, lazy_discover: bool = True, timeout: Optional[int] = None, - model: str = MODEL_FAN_1C, + *, + model: Optional[str] = MODEL_FAN_1C, ) -> None: super().__init__( ip, token, start_id, debug, lazy_discover, timeout=timeout, model=model diff --git a/miio/integrations/huayi/light/huizuo.py b/miio/integrations/huayi/light/huizuo.py index 60811e67c..8bc495b3a 100644 --- a/miio/integrations/huayi/light/huizuo.py +++ b/miio/integrations/huayi/light/huizuo.py @@ -218,7 +218,8 @@ def __init__( debug: int = 0, lazy_discover: bool = True, timeout: Optional[int] = None, - model: str = MODEL_HUIZUO_PIS123, + *, + model: Optional[str] = MODEL_HUIZUO_PIS123, ) -> None: if model in MODELS_WITH_FAN_WY: self.mapping.update(_ADDITIONAL_MAPPING_FAN_WY) diff --git a/miio/integrations/lumi/acpartner/airconditioningcompanion.py b/miio/integrations/lumi/acpartner/airconditioningcompanion.py index 520363f23..5497d085d 100644 --- a/miio/integrations/lumi/acpartner/airconditioningcompanion.py +++ b/miio/integrations/lumi/acpartner/airconditioningcompanion.py @@ -232,7 +232,8 @@ def __init__( debug: int = 0, lazy_discover: bool = True, timeout: Optional[int] = None, - model: str = MODEL_ACPARTNER_V2, + *, + model: Optional[str] = MODEL_ACPARTNER_V2, ) -> None: super().__init__( ip, token, start_id, debug, lazy_discover, timeout=timeout, model=model diff --git a/miio/integrations/lumi/acpartner/airconditioningcompanionMCN.py b/miio/integrations/lumi/acpartner/airconditioningcompanionMCN.py index d463502b2..13b571a4e 100644 --- a/miio/integrations/lumi/acpartner/airconditioningcompanionMCN.py +++ b/miio/integrations/lumi/acpartner/airconditioningcompanionMCN.py @@ -107,7 +107,8 @@ def __init__( debug: int = 0, lazy_discover: bool = True, timeout: Optional[int] = None, - model: str = MODEL_ACPARTNER_MCN02, + *, + model: Optional[str] = MODEL_ACPARTNER_MCN02, ) -> None: if start_id is None: start_id = random.randint(0, 999) # nosec diff --git a/miio/integrations/yeelight/light/yeelight.py b/miio/integrations/yeelight/light/yeelight.py index 2c571b691..72f96541e 100644 --- a/miio/integrations/yeelight/light/yeelight.py +++ b/miio/integrations/yeelight/light/yeelight.py @@ -279,6 +279,7 @@ def __init__( debug: int = 0, lazy_discover: bool = True, timeout: Optional[int] = None, + *, model: Optional[str] = None, ) -> None: super().__init__( diff --git a/miio/tests/test_device.py b/miio/tests/test_device.py index b9e0a83ab..aa75e8aaa 100644 --- a/miio/tests/test_device.py +++ b/miio/tests/test_device.py @@ -1,3 +1,4 @@ +import inspect import math import pytest @@ -162,6 +163,27 @@ def test_init_signature(cls, mocker): assert total_args == 8 +@pytest.mark.parametrize("cls", DEVICE_CLASSES) +def test_model_is_keyword_only(cls: type) -> None: + """Ensure 'model' is keyword-only in all Device subclasses. + + The base Device class defines model as keyword-only (after *). Subclasses + that make it positional break Liskov substitution and can cause subtle bugs + when classes are used interchangeably via DeviceFactory. + """ + sig: inspect.Signature = inspect.signature(cls.__init__) + params: dict[str, inspect.Parameter] = dict(sig.parameters) + + if "model" not in params: + return + + param: inspect.Parameter = params["model"] + assert param.kind == inspect.Parameter.KEYWORD_ONLY, ( + f"{cls.__name__}.__init__ has 'model' as positional parameter, " + f"it should be keyword-only (after *)" + ) + + @pytest.mark.parametrize("cls", DEVICE_CLASSES) def test_status_return_type(cls): """Make sure that all status methods have a type hint."""