From 82c94826fbb6abaac4e82230151065bd4ea4eca4 Mon Sep 17 00:00:00 2001 From: Ruslan Sayfutdinov Date: Tue, 30 Mar 2021 17:48:04 +0100 Subject: [PATCH] Enable strict typing for zeroconf (#48450) * Enable strict typing for zeroconf * Fix lutron_caseta * Fix pylint warning * Fix tests * Fix xiaomi_aqara test * Add __init__.py in homeassistant.generated module * Restore add_job with type: ignore --- .../components/lutron_caseta/config_flow.py | 3 +- homeassistant/components/zeroconf/__init__.py | 129 ++++++++---------- homeassistant/components/zeroconf/models.py | 33 +++++ homeassistant/components/zeroconf/usage.py | 13 +- homeassistant/generated/__init__.py | 4 + setup.cfg | 2 +- .../lutron_caseta/test_config_flow.py | 3 +- .../xiaomi_aqara/test_config_flow.py | 7 +- .../xiaomi_miio/test_config_flow.py | 9 +- 9 files changed, 111 insertions(+), 92 deletions(-) create mode 100644 homeassistant/components/zeroconf/models.py create mode 100644 homeassistant/generated/__init__.py diff --git a/homeassistant/components/lutron_caseta/config_flow.py b/homeassistant/components/lutron_caseta/config_flow.py index 3c5b1e5db97..f591369b570 100644 --- a/homeassistant/components/lutron_caseta/config_flow.py +++ b/homeassistant/components/lutron_caseta/config_flow.py @@ -10,7 +10,6 @@ from pylutron_caseta.smartbridge import Smartbridge import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.zeroconf import ATTR_HOSTNAME from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import callback @@ -66,7 +65,7 @@ class LutronCasetaFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_zeroconf(self, discovery_info): """Handle a flow initialized by zeroconf discovery.""" - hostname = discovery_info[ATTR_HOSTNAME] + hostname = discovery_info["hostname"] if hostname is None or not hostname.startswith("lutron-"): return self.async_abort(reason="not_lutron_device") diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 58dbe2125c1..38544798b9b 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -7,16 +7,14 @@ from functools import partial import ipaddress import logging import socket +from typing import Any, TypedDict import voluptuous as vol from zeroconf import ( - DNSPointer, - DNSRecord, Error as ZeroconfError, InterfaceChoice, IPVersion, NonUniqueNameException, - ServiceBrowser, ServiceInfo, ServiceStateChange, Zeroconf, @@ -24,29 +22,24 @@ from zeroconf import ( from homeassistant import util from homeassistant.const import ( - ATTR_NAME, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, __version__, ) +from homeassistant.core import Event, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.network import NoURLAvailableError, get_url from homeassistant.helpers.singleton import singleton from homeassistant.loader import async_get_homekit, async_get_zeroconf +from .models import HaServiceBrowser, HaZeroconf from .usage import install_multiple_zeroconf_catcher _LOGGER = logging.getLogger(__name__) DOMAIN = "zeroconf" -ATTR_HOST = "host" -ATTR_PORT = "port" -ATTR_HOSTNAME = "hostname" -ATTR_TYPE = "type" -ATTR_PROPERTIES = "properties" - ZEROCONF_TYPE = "_home-assistant._tcp.local." HOMEKIT_TYPES = [ "_hap._tcp.local.", @@ -59,7 +52,6 @@ CONF_IPV6 = "ipv6" DEFAULT_DEFAULT_INTERFACE = True DEFAULT_IPV6 = True -HOMEKIT_PROPERTIES = "properties" HOMEKIT_PAIRED_STATUS_FLAG = "sf" HOMEKIT_MODEL = "md" @@ -85,20 +77,31 @@ CONFIG_SCHEMA = vol.Schema( ) +class HaServiceInfo(TypedDict): + """Prepared info from mDNS entries.""" + + host: str + port: int | None + hostname: str + type: str + name: str + properties: dict[str, Any] + + @singleton(DOMAIN) -async def async_get_instance(hass): +async def async_get_instance(hass: HomeAssistant) -> HaZeroconf: """Zeroconf instance to be shared with other integrations that use it.""" return await _async_get_instance(hass) -async def _async_get_instance(hass, **zcargs): +async def _async_get_instance(hass: HomeAssistant, **zcargs: Any) -> HaZeroconf: logging.getLogger("zeroconf").setLevel(logging.NOTSET) zeroconf = await hass.async_add_executor_job(partial(HaZeroconf, **zcargs)) install_multiple_zeroconf_catcher(zeroconf) - def _stop_zeroconf(_): + def _stop_zeroconf(_event: Event) -> None: """Stop Zeroconf.""" zeroconf.ha_close() @@ -107,40 +110,10 @@ async def _async_get_instance(hass, **zcargs): return zeroconf -class HaServiceBrowser(ServiceBrowser): - """ServiceBrowser that only consumes DNSPointer records.""" - - def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: - """Pre-Filter update_record to DNSPointers for the configured type.""" - - # - # Each ServerBrowser currently runs in its own thread which - # processes every A or AAAA record update per instance. - # - # As the list of zeroconf names we watch for grows, each additional - # ServiceBrowser would process all the A and AAAA updates on the network. - # - # To avoid overwhemling the system we pre-filter here and only process - # DNSPointers for the configured record name (type) - # - if record.name not in self.types or not isinstance(record, DNSPointer): - return - super().update_record(zc, now, record) - - -class HaZeroconf(Zeroconf): - """Zeroconf that cannot be closed.""" - - def close(self): - """Fake method to avoid integrations closing it.""" - - ha_close = Zeroconf.close - - -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistant, config: dict) -> bool: """Set up Zeroconf and make Home Assistant discoverable.""" zc_config = config.get(DOMAIN, {}) - zc_args = {} + zc_args: dict = {} if zc_config.get(CONF_DEFAULT_INTERFACE, DEFAULT_DEFAULT_INTERFACE): zc_args["interfaces"] = InterfaceChoice.Default if not zc_config.get(CONF_IPV6, DEFAULT_IPV6): @@ -148,7 +121,7 @@ async def async_setup(hass, config): zeroconf = hass.data[DOMAIN] = await _async_get_instance(hass, **zc_args) - async def _async_zeroconf_hass_start(_event): + async def _async_zeroconf_hass_start(_event: Event) -> None: """Expose Home Assistant on zeroconf when it starts. Wait till started or otherwise HTTP is not up and running. @@ -158,7 +131,7 @@ async def async_setup(hass, config): _register_hass_zc_service, hass, zeroconf, uuid ) - async def _async_zeroconf_hass_started(_event): + async def _async_zeroconf_hass_started(_event: Event) -> None: """Start the service browser.""" await _async_start_zeroconf_browser(hass, zeroconf) @@ -171,7 +144,9 @@ async def async_setup(hass, config): return True -def _register_hass_zc_service(hass, zeroconf, uuid): +def _register_hass_zc_service( + hass: HomeAssistant, zeroconf: HaZeroconf, uuid: str +) -> None: # Get instance UUID valid_location_name = _truncate_location_name_to_valid(hass.config.location_name) @@ -224,7 +199,9 @@ def _register_hass_zc_service(hass, zeroconf, uuid): ) -async def _async_start_zeroconf_browser(hass, zeroconf): +async def _async_start_zeroconf_browser( + hass: HomeAssistant, zeroconf: HaZeroconf +) -> None: """Start the zeroconf browser.""" zeroconf_types = await async_get_zeroconf(hass) @@ -236,7 +213,12 @@ async def _async_start_zeroconf_browser(hass, zeroconf): if hk_type not in zeroconf_types: types.append(hk_type) - def service_update(zeroconf, service_type, name, state_change): + def service_update( + zeroconf: Zeroconf, + service_type: str, + name: str, + state_change: ServiceStateChange, + ) -> None: """Service state changed.""" nonlocal zeroconf_types nonlocal homekit_models @@ -276,12 +258,11 @@ async def _async_start_zeroconf_browser(hass, zeroconf): # offering a second discovery for the same device if ( discovery_was_forwarded - and HOMEKIT_PROPERTIES in info - and HOMEKIT_PAIRED_STATUS_FLAG in info[HOMEKIT_PROPERTIES] + and HOMEKIT_PAIRED_STATUS_FLAG in info["properties"] ): try: # 0 means paired and not discoverable by iOS clients) - if int(info[HOMEKIT_PROPERTIES][HOMEKIT_PAIRED_STATUS_FLAG]): + if int(info["properties"][HOMEKIT_PAIRED_STATUS_FLAG]): return except ValueError: # HomeKit pairing status unknown @@ -289,12 +270,12 @@ async def _async_start_zeroconf_browser(hass, zeroconf): return if "name" in info: - lowercase_name = info["name"].lower() + lowercase_name: str | None = info["name"].lower() else: lowercase_name = None - if "macaddress" in info.get("properties", {}): - uppercase_mac = info["properties"]["macaddress"].upper() + if "macaddress" in info["properties"]: + uppercase_mac: str | None = info["properties"]["macaddress"].upper() else: uppercase_mac = None @@ -318,20 +299,22 @@ async def _async_start_zeroconf_browser(hass, zeroconf): hass.add_job( hass.config_entries.flow.async_init( entry["domain"], context={"source": DOMAIN}, data=info - ) + ) # type: ignore ) _LOGGER.debug("Starting Zeroconf browser") HaServiceBrowser(zeroconf, types, handlers=[service_update]) -def handle_homekit(hass, homekit_models, info) -> bool: +def handle_homekit( + hass: HomeAssistant, homekit_models: dict[str, str], info: HaServiceInfo +) -> bool: """Handle a HomeKit discovery. Return if discovery was forwarded. """ model = None - props = info.get(HOMEKIT_PROPERTIES, {}) + props = info["properties"] for key in props: if key.lower() == HOMEKIT_MODEL: @@ -352,16 +335,16 @@ def handle_homekit(hass, homekit_models, info) -> bool: hass.add_job( hass.config_entries.flow.async_init( homekit_models[test_model], context={"source": "homekit"}, data=info - ) + ) # type: ignore ) return True return False -def info_from_service(service): +def info_from_service(service: ServiceInfo) -> HaServiceInfo | None: """Return prepared info from mDNS entries.""" - properties = {"_raw": {}} + properties: dict[str, Any] = {"_raw": {}} for key, value in service.properties.items(): # See https://ietf.org/rfc/rfc6763.html#section-6.4 and @@ -386,19 +369,17 @@ def info_from_service(service): address = service.addresses[0] - info = { - ATTR_HOST: str(ipaddress.ip_address(address)), - ATTR_PORT: service.port, - ATTR_HOSTNAME: service.server, - ATTR_TYPE: service.type, - ATTR_NAME: service.name, - ATTR_PROPERTIES: properties, + return { + "host": str(ipaddress.ip_address(address)), + "port": service.port, + "hostname": service.server, + "type": service.type, + "name": service.name, + "properties": properties, } - return info - -def _suppress_invalid_properties(properties): +def _suppress_invalid_properties(properties: dict) -> None: """Suppress any properties that will cause zeroconf to fail to startup.""" for prop, prop_value in properties.items(): @@ -415,7 +396,7 @@ def _suppress_invalid_properties(properties): properties[prop] = "" -def _truncate_location_name_to_valid(location_name): +def _truncate_location_name_to_valid(location_name: str) -> str: """Truncate or return the location name usable for zeroconf.""" if len(location_name.encode("utf-8")) < MAX_NAME_LEN: return location_name diff --git a/homeassistant/components/zeroconf/models.py b/homeassistant/components/zeroconf/models.py new file mode 100644 index 00000000000..02a6fc7cdaa --- /dev/null +++ b/homeassistant/components/zeroconf/models.py @@ -0,0 +1,33 @@ +"""Models for Zeroconf.""" + +from zeroconf import DNSPointer, DNSRecord, ServiceBrowser, Zeroconf + + +class HaZeroconf(Zeroconf): + """Zeroconf that cannot be closed.""" + + def close(self) -> None: + """Fake method to avoid integrations closing it.""" + + ha_close = Zeroconf.close + + +class HaServiceBrowser(ServiceBrowser): + """ServiceBrowser that only consumes DNSPointer records.""" + + def update_record(self, zc: Zeroconf, now: float, record: DNSRecord) -> None: + """Pre-Filter update_record to DNSPointers for the configured type.""" + + # + # Each ServerBrowser currently runs in its own thread which + # processes every A or AAAA record update per instance. + # + # As the list of zeroconf names we watch for grows, each additional + # ServiceBrowser would process all the A and AAAA updates on the network. + # + # To avoid overwhemling the system we pre-filter here and only process + # DNSPointers for the configured record name (type) + # + if record.name not in self.types or not isinstance(record, DNSPointer): + return + super().update_record(zc, now, record) diff --git a/homeassistant/components/zeroconf/usage.py b/homeassistant/components/zeroconf/usage.py index 4b4af0dd79d..f7689ab63a4 100644 --- a/homeassistant/components/zeroconf/usage.py +++ b/homeassistant/components/zeroconf/usage.py @@ -2,6 +2,7 @@ from contextlib import suppress import logging +from typing import Any import zeroconf @@ -11,23 +12,25 @@ from homeassistant.helpers.frame import ( report_integration, ) +from .models import HaZeroconf + _LOGGER = logging.getLogger(__name__) -def install_multiple_zeroconf_catcher(hass_zc) -> None: +def install_multiple_zeroconf_catcher(hass_zc: HaZeroconf) -> None: """Wrap the Zeroconf class to return the shared instance if multiple instances are detected.""" - def new_zeroconf_new(self, *k, **kw): + def new_zeroconf_new(self: zeroconf.Zeroconf, *k: Any, **kw: Any) -> HaZeroconf: _report( "attempted to create another Zeroconf instance. Please use the shared Zeroconf via await homeassistant.components.zeroconf.async_get_instance(hass)", ) return hass_zc - def new_zeroconf_init(self, *k, **kw): + def new_zeroconf_init(self: zeroconf.Zeroconf, *k: Any, **kw: Any) -> None: return - zeroconf.Zeroconf.__new__ = new_zeroconf_new - zeroconf.Zeroconf.__init__ = new_zeroconf_init + zeroconf.Zeroconf.__new__ = new_zeroconf_new # type: ignore + zeroconf.Zeroconf.__init__ = new_zeroconf_init # type: ignore def _report(what: str) -> None: diff --git a/homeassistant/generated/__init__.py b/homeassistant/generated/__init__.py new file mode 100644 index 00000000000..b86c779f9b8 --- /dev/null +++ b/homeassistant/generated/__init__.py @@ -0,0 +1,4 @@ +"""All files in this module are automatically generated by hassfest. + +To update, run python3 -m script.hassfest +""" diff --git a/setup.cfg b/setup.cfg index 408bab3a03c..65b598f4f6f 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,7 +43,7 @@ warn_redundant_casts = true warn_unused_configs = true -[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.bond.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.huawei_lte.*,homeassistant.components.hyperion.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.knx.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.number.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.recorder.purge,homeassistant.components.recorder.repack,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.slack.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zone.*,homeassistant.components.zwave_js.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,tests.components.hyperion.*] +[mypy-homeassistant.block_async_io,homeassistant.bootstrap,homeassistant.components,homeassistant.config_entries,homeassistant.config,homeassistant.const,homeassistant.core,homeassistant.data_entry_flow,homeassistant.exceptions,homeassistant.__init__,homeassistant.loader,homeassistant.__main__,homeassistant.requirements,homeassistant.runner,homeassistant.setup,homeassistant.util,homeassistant.auth.*,homeassistant.components.automation.*,homeassistant.components.binary_sensor.*,homeassistant.components.bond.*,homeassistant.components.calendar.*,homeassistant.components.cover.*,homeassistant.components.device_automation.*,homeassistant.components.frontend.*,homeassistant.components.geo_location.*,homeassistant.components.group.*,homeassistant.components.history.*,homeassistant.components.http.*,homeassistant.components.huawei_lte.*,homeassistant.components.hyperion.*,homeassistant.components.image_processing.*,homeassistant.components.integration.*,homeassistant.components.knx.*,homeassistant.components.light.*,homeassistant.components.lock.*,homeassistant.components.mailbox.*,homeassistant.components.media_player.*,homeassistant.components.notify.*,homeassistant.components.number.*,homeassistant.components.persistent_notification.*,homeassistant.components.proximity.*,homeassistant.components.recorder.purge,homeassistant.components.recorder.repack,homeassistant.components.remote.*,homeassistant.components.scene.*,homeassistant.components.sensor.*,homeassistant.components.slack.*,homeassistant.components.sun.*,homeassistant.components.switch.*,homeassistant.components.systemmonitor.*,homeassistant.components.tts.*,homeassistant.components.vacuum.*,homeassistant.components.water_heater.*,homeassistant.components.weather.*,homeassistant.components.websocket_api.*,homeassistant.components.zeroconf.*,homeassistant.components.zone.*,homeassistant.components.zwave_js.*,homeassistant.helpers.*,homeassistant.scripts.*,homeassistant.util.*,tests.components.hyperion.*] strict = true ignore_errors = false warn_unreachable = true diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 58377c8e085..e65eb9d5f47 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -17,11 +17,12 @@ from homeassistant.components.lutron_caseta.const import ( ERROR_CANNOT_CONNECT, STEP_IMPORT_FAILED, ) -from homeassistant.components.zeroconf import ATTR_HOSTNAME from homeassistant.const import CONF_HOST from tests.common import MockConfigEntry +ATTR_HOSTNAME = "hostname" + EMPTY_MOCK_CONFIG_ENTRY = { CONF_HOST: "", CONF_KEYFILE: "", diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index f52f9b8e64d..859338b82d3 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -5,7 +5,6 @@ from unittest.mock import Mock, patch import pytest from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_aqara import config_flow, const from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, CONF_PORT, CONF_PROTOCOL @@ -402,7 +401,7 @@ async def test_zeroconf_success(hass): const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data={ - zeroconf.ATTR_HOST: TEST_HOST, + CONF_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME, ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, }, @@ -444,7 +443,7 @@ async def test_zeroconf_missing_data(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={zeroconf.ATTR_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME}, + data={CONF_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME}, ) assert result["type"] == "abort" @@ -457,7 +456,7 @@ async def test_zeroconf_unknown_device(hass): const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data={ - zeroconf.ATTR_HOST: TEST_HOST, + CONF_HOST: TEST_HOST, ZEROCONF_NAME: "not-a-xiaomi-aqara-gateway", ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, }, diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index f53fe6e40b4..de1ccbf1a8b 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -4,7 +4,6 @@ from unittest.mock import Mock, patch from miio import DeviceException from homeassistant import config_entries -from homeassistant.components import zeroconf from homeassistant.components.xiaomi_miio import const from homeassistant.components.xiaomi_miio.config_flow import DEFAULT_GATEWAY_NAME from homeassistant.const import CONF_HOST, CONF_NAME, CONF_TOKEN @@ -106,7 +105,7 @@ async def test_zeroconf_gateway_success(hass): const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data={ - zeroconf.ATTR_HOST: TEST_HOST, + CONF_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME, ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, }, @@ -146,7 +145,7 @@ async def test_zeroconf_unknown_device(hass): const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data={ - zeroconf.ATTR_HOST: TEST_HOST, + CONF_HOST: TEST_HOST, ZEROCONF_NAME: "not-a-xiaomi-miio-device", ZEROCONF_PROP: {ZEROCONF_MAC: TEST_MAC}, }, @@ -171,7 +170,7 @@ async def test_zeroconf_missing_data(hass): result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, - data={zeroconf.ATTR_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME}, + data={CONF_HOST: TEST_HOST, ZEROCONF_NAME: TEST_ZEROCONF_NAME}, ) assert result["type"] == "abort" @@ -342,7 +341,7 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data={ - zeroconf.ATTR_HOST: TEST_HOST, + CONF_HOST: TEST_HOST, ZEROCONF_NAME: zeroconf_name_to_test, ZEROCONF_PROP: {"poch": f"0:mac={TEST_MAC_DEVICE}\x00"}, },