diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index b757f1f4d03..8c8ba318e83 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -7,6 +7,7 @@ from pydeconz.utils import async_discovery, async_get_api_key, async_get_gateway import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_SERIAL from homeassistant.const import CONF_API_KEY, CONF_HOST, CONF_PORT from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -169,10 +170,6 @@ class DeconzFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): async def async_step_ssdp(self, discovery_info): """Handle a discovered deCONZ bridge.""" - # Import it here, because only now do we know ssdp integration loaded. - # pylint: disable=import-outside-toplevel - from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_SERIAL - if discovery_info[ATTR_MANUFACTURERURL] != DECONZ_MANUFACTURERURL: return self.async_abort(reason="not_deconz_bridge") diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 84b435d02ed..0423dc6fc2b 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -8,6 +8,7 @@ import async_timeout import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_NAME from homeassistant.core import callback from homeassistant.helpers import aiohttp_client @@ -134,9 +135,6 @@ class HueFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): This flow is triggered by the SSDP component. It will check if the host is already configured and delegate to the import step if not. """ - # pylint: disable=import-outside-toplevel - from homeassistant.components.ssdp import ATTR_MANUFACTURERURL, ATTR_NAME - if discovery_info[ATTR_MANUFACTURERURL] != HUE_MANUFACTURERURL: return self.async_abort(reason="not_hue_bridge") diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 0e1ee8ae756..9e8ea9fc7ca 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -195,22 +195,45 @@ class Integration: hass: "HomeAssistant", pkg_path: str, file_path: pathlib.Path, - manifest: Dict, + manifest: Dict[str, Any], ): """Initialize an integration.""" self.hass = hass self.pkg_path = pkg_path self.file_path = file_path - self.name: str = manifest["name"] - self.domain: str = manifest["domain"] - self.dependencies: List[str] = manifest["dependencies"] - self.after_dependencies: Optional[List[str]] = manifest.get( - "after_dependencies" - ) - self.requirements: List[str] = manifest["requirements"] - self.config_flow: bool = manifest.get("config_flow", False) + self.manifest = manifest _LOGGER.info("Loaded %s from %s", self.domain, pkg_path) + @property + def name(self) -> str: + """Return name.""" + return cast(str, self.manifest["name"]) + + @property + def domain(self) -> str: + """Return domain.""" + return cast(str, self.manifest["domain"]) + + @property + def dependencies(self) -> List[str]: + """Return dependencies.""" + return cast(List[str], self.manifest.get("dependencies", [])) + + @property + def after_dependencies(self) -> List[str]: + """Return after_dependencies.""" + return cast(List[str], self.manifest.get("after_dependencies", [])) + + @property + def requirements(self) -> List[str]: + """Return requirements.""" + return cast(List[str], self.manifest.get("requirements", [])) + + @property + def config_flow(self) -> bool: + """Return config_flow.""" + return cast(bool, self.manifest.get("config_flow", False)) + @property def is_built_in(self) -> bool: """Test if package is a built-in integration.""" diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index bd52253cdb1..670f3af7dcc 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -3,7 +3,7 @@ import asyncio import logging import os from pathlib import Path -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, Iterable from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -15,6 +15,10 @@ DATA_PKG_CACHE = "pkg_cache" CONSTRAINT_FILE = "package_constraints.txt" PROGRESS_FILE = ".pip_progress" _LOGGER = logging.getLogger(__name__) +DISCOVERY_INTEGRATIONS: Dict[str, Iterable[str]] = { + "ssdp": ("ssdp",), + "zeroconf": ("zeroconf", "homekit"), +} class RequirementsNotFound(HomeAssistantError): @@ -30,7 +34,7 @@ class RequirementsNotFound(HomeAssistantError): async def async_get_integration_with_requirements( hass: HomeAssistant, domain: str, done: Set[str] = None ) -> Integration: - """Get an integration with installed requirements. + """Get an integration with all requirements installed, including the dependencies. This can raise IntegrationNotFound if manifest or integration is invalid, RequirementNotFound if there was some type of @@ -53,10 +57,18 @@ async def async_get_integration_with_requirements( deps_to_check = [ dep - for dep in integration.dependencies + (integration.after_dependencies or []) + for dep in integration.dependencies + integration.after_dependencies if dep not in done ] + for check_domain, to_check in DISCOVERY_INTEGRATIONS.items(): + if ( + check_domain not in done + and check_domain not in deps_to_check + and any(check in integration.manifest for check in to_check) + ): + deps_to_check.append(check_domain) + if deps_to_check: await asyncio.gather( *[ diff --git a/script/hassfest/dependencies.py b/script/hassfest/dependencies.py index 71936411b75..b6f277438b4 100644 --- a/script/hassfest/dependencies.py +++ b/script/hassfest/dependencies.py @@ -3,6 +3,8 @@ import pathlib import re from typing import Dict, Set +from homeassistant.requirements import DISCOVERY_INTEGRATIONS + from .model import Integration @@ -49,7 +51,6 @@ ALLOWED_USED_COMPONENTS = { "system_log", "person", # Discovery - "ssdp", "discovery", # Other "mjpeg", # base class, has no reqs or component to load. @@ -93,6 +94,13 @@ def validate_dependencies(integration: Integration): referenced -= set(integration.manifest["dependencies"]) referenced -= set(integration.manifest.get("after_dependencies", [])) + # Discovery requirements are ok if referenced in manifest + for check_domain, to_check in DISCOVERY_INTEGRATIONS.items(): + if check_domain in referenced and any( + check in integration.manifest for check in to_check + ): + referenced.remove(check_domain) + if referenced: for domain in sorted(referenced): if ( diff --git a/tests/test_requirements.py b/tests/test_requirements.py index b807188e8a5..87bbf38e465 100644 --- a/tests/test_requirements.py +++ b/tests/test_requirements.py @@ -2,10 +2,9 @@ import os from pathlib import Path from unittest.mock import call, patch +import pytest -from pytest import raises - -from homeassistant import setup +from homeassistant import setup, loader from homeassistant.requirements import ( CONSTRAINT_FILE, PROGRESS_FILE, @@ -15,7 +14,12 @@ from homeassistant.requirements import ( async_process_requirements, ) -from tests.common import MockModule, get_test_home_assistant, mock_integration +from tests.common import ( + MockModule, + get_test_home_assistant, + mock_coro, + mock_integration, +) def env_without_wheel_links(): @@ -104,7 +108,7 @@ async def test_install_missing_package(hass): with patch( "homeassistant.util.package.install_package", return_value=False ) as mock_inst: - with raises(RequirementsNotFound): + with pytest.raises(RequirementsNotFound): await async_process_requirements(hass, "test_component", ["hello==1.0.0"]) assert len(mock_inst.mock_calls) == 1 @@ -222,3 +226,44 @@ async def test_progress_lock(hass): _install(hass, "hello", kwargs) assert not progress_path.exists() + + +async def test_discovery_requirements_ssdp(hass): + """Test that we load discovery requirements.""" + hass.config.skip_pip = False + ssdp = await loader.async_get_integration(hass, "ssdp") + + mock_integration( + hass, MockModule("ssdp_comp", partial_manifest={"ssdp": [{"st": "roku:ecp"}]}) + ) + with patch( + "homeassistant.requirements.async_process_requirements", + side_effect=lambda _, _2, _3: mock_coro(), + ) as mock_process: + await async_get_integration_with_requirements(hass, "ssdp_comp") + + assert len(mock_process.mock_calls) == 1 + assert mock_process.mock_calls[0][1][2] == ssdp.requirements + + +@pytest.mark.parametrize( + "partial_manifest", + [{"zeroconf": ["_googlecast._tcp.local."]}, {"homekit": {"models": ["LIFX"]}}], +) +async def test_discovery_requirements_zeroconf(hass, partial_manifest): + """Test that we load discovery requirements.""" + hass.config.skip_pip = False + zeroconf = await loader.async_get_integration(hass, "zeroconf") + + mock_integration( + hass, MockModule("comp", partial_manifest=partial_manifest), + ) + + with patch( + "homeassistant.requirements.async_process_requirements", + side_effect=lambda _, _2, _3: mock_coro(), + ) as mock_process: + await async_get_integration_with_requirements(hass, "comp") + + assert len(mock_process.mock_calls) == 2 # zeroconf also depends on http + assert mock_process.mock_calls[0][1][2] == zeroconf.requirements