From adf0f629634d5a5d83174bc850b509996acfc490 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 21 Jun 2022 13:09:22 -0500 Subject: [PATCH] Add websocket api to fetch config entries (#73570) * Add websocket api to fetch config entries * add coverage for failure case --- .../components/config/config_entries.py | 118 +++++++---- .../components/config/test_config_entries.py | 192 +++++++++++++++++- 2 files changed, 265 insertions(+), 45 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index b1756b58c3e..ac452666103 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -3,22 +3,29 @@ from __future__ import annotations import asyncio from http import HTTPStatus +from typing import Any from aiohttp import web import aiohttp.web_exceptions import voluptuous as vol -from homeassistant import config_entries, data_entry_flow, loader +from homeassistant import config_entries, data_entry_flow from homeassistant.auth.permissions.const import CAT_CONFIG_ENTRIES, POLICY_EDIT from homeassistant.components import websocket_api from homeassistant.components.http import HomeAssistantView +from homeassistant.components.websocket_api.connection import ActiveConnection from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import DependencyError, Unauthorized from homeassistant.helpers.data_entry_flow import ( FlowManagerIndexView, FlowManagerResourceView, ) -from homeassistant.loader import Integration, async_get_config_flows +from homeassistant.loader import ( + Integration, + IntegrationNotFound, + async_get_config_flows, + async_get_integration, +) async def async_setup(hass): @@ -33,6 +40,7 @@ async def async_setup(hass): hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) + websocket_api.async_register_command(hass, config_entries_get) websocket_api.async_register_command(hass, config_entry_disable) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_progress) @@ -50,49 +58,13 @@ class ConfigManagerEntryIndexView(HomeAssistantView): async def get(self, request): """List available config entries.""" hass: HomeAssistant = request.app["hass"] - - kwargs = {} + domain = None if "domain" in request.query: - kwargs["domain"] = request.query["domain"] - - entries = hass.config_entries.async_entries(**kwargs) - - if "type" not in request.query: - return self.json([entry_json(entry) for entry in entries]) - - integrations = {} - type_filter = request.query["type"] - - async def load_integration( - hass: HomeAssistant, domain: str - ) -> Integration | None: - """Load integration.""" - try: - return await loader.async_get_integration(hass, domain) - except loader.IntegrationNotFound: - return None - - # Fetch all the integrations so we can check their type - for integration in await asyncio.gather( - *( - load_integration(hass, domain) - for domain in {entry.domain for entry in entries} - ) - ): - if integration: - integrations[integration.domain] = integration - - entries = [ - entry - for entry in entries - if (type_filter != "helper" and entry.domain not in integrations) - or ( - entry.domain in integrations - and integrations[entry.domain].integration_type == type_filter - ) - ] - - return self.json([entry_json(entry) for entry in entries]) + domain = request.query["domain"] + type_filter = None + if "type" in request.query: + type_filter = request.query["type"] + return self.json(await async_matching_config_entries(hass, type_filter, domain)) class ConfigManagerEntryResourceView(HomeAssistantView): @@ -415,6 +387,64 @@ async def ignore_config_flow(hass, connection, msg): connection.send_result(msg["id"]) +@websocket_api.websocket_command( + { + vol.Required("type"): "config_entries/get", + vol.Optional("type_filter"): str, + vol.Optional("domain"): str, + } +) +@websocket_api.async_response +async def config_entries_get( + hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] +) -> None: + """Return matching config entries by type and/or domain.""" + connection.send_result( + msg["id"], + await async_matching_config_entries( + hass, msg.get("type_filter"), msg.get("domain") + ), + ) + + +async def async_matching_config_entries( + hass: HomeAssistant, type_filter: str | None, domain: str | None +) -> list[dict[str, Any]]: + """Return matching config entries by type and/or domain.""" + kwargs = {} + if domain: + kwargs["domain"] = domain + entries = hass.config_entries.async_entries(**kwargs) + + if type_filter is None: + return [entry_json(entry) for entry in entries] + + integrations = {} + # Fetch all the integrations so we can check their type + tasks = ( + async_get_integration(hass, domain) + for domain in {entry.domain for entry in entries} + ) + results = await asyncio.gather(*tasks, return_exceptions=True) + for integration_or_exc in results: + if isinstance(integration_or_exc, Integration): + integrations[integration_or_exc.domain] = integration_or_exc + elif not isinstance(integration_or_exc, IntegrationNotFound): + raise integration_or_exc + + entries = [ + entry + for entry in entries + if (type_filter != "helper" and entry.domain not in integrations) + or ( + entry.domain in integrations + and integrations[entry.domain].integration_type == type_filter + ) + ] + + return [entry_json(entry) for entry in entries] + + @callback def entry_json(entry: config_entries.ConfigEntry) -> dict: """Return JSON value of a config entry.""" diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index bb0d67fc306..611a7f75939 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -2,7 +2,7 @@ from collections import OrderedDict from http import HTTPStatus -from unittest.mock import AsyncMock, patch +from unittest.mock import ANY, AsyncMock, patch import pytest import voluptuous as vol @@ -13,6 +13,7 @@ from homeassistant.config_entries import HANDLERS, ConfigFlow from homeassistant.core import callback from homeassistant.generated import config_flows from homeassistant.helpers import config_entry_flow, config_validation as cv +from homeassistant.loader import IntegrationNotFound from homeassistant.setup import async_setup_component from tests.common import ( @@ -1113,3 +1114,192 @@ async def test_ignore_flow_nonexisting(hass, hass_ws_client): assert not response["success"] assert response["error"]["code"] == "not_found" + + +async def test_get_entries_ws(hass, hass_ws_client, clear_handlers): + """Test get entries with the websocket api.""" + assert await async_setup_component(hass, "config", {}) + mock_integration(hass, MockModule("comp1")) + mock_integration( + hass, MockModule("comp2", partial_manifest={"integration_type": "helper"}) + ) + mock_integration(hass, MockModule("comp3")) + entry = MockConfigEntry( + domain="comp1", + title="Test 1", + source="bla", + ) + entry.add_to_hass(hass) + MockConfigEntry( + domain="comp2", + title="Test 2", + source="bla2", + state=core_ce.ConfigEntryState.SETUP_ERROR, + reason="Unsupported API", + ).add_to_hass(hass) + MockConfigEntry( + domain="comp3", + title="Test 3", + source="bla3", + disabled_by=core_ce.ConfigEntryDisabler.USER, + ).add_to_hass(hass) + + ws_client = await hass_ws_client(hass) + + await ws_client.send_json( + { + "id": 5, + "type": "config_entries/get", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 5 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + { + "disabled_by": None, + "domain": "comp2", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": "Unsupported API", + "source": "bla2", + "state": "setup_error", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 2", + }, + { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + ] + + await ws_client.send_json( + { + "id": 6, + "type": "config_entries/get", + "domain": "comp1", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + assert response["id"] == 6 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + } + ] + # Verify we skip broken integrations + + with patch( + "homeassistant.components.config.config_entries.async_get_integration", + side_effect=IntegrationNotFound("any"), + ): + await ws_client.send_json( + { + "id": 7, + "type": "config_entries/get", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + + assert response["id"] == 7 + assert response["result"] == [ + { + "disabled_by": None, + "domain": "comp1", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 1", + }, + { + "disabled_by": None, + "domain": "comp2", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": "Unsupported API", + "source": "bla2", + "state": "setup_error", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 2", + }, + { + "disabled_by": "user", + "domain": "comp3", + "entry_id": ANY, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "bla3", + "state": "not_loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Test 3", + }, + ] + + # Verify we raise if something really goes wrong + + with patch( + "homeassistant.components.config.config_entries.async_get_integration", + side_effect=Exception, + ): + await ws_client.send_json( + { + "id": 8, + "type": "config_entries/get", + "type_filter": "integration", + } + ) + response = await ws_client.receive_json() + + assert response["id"] == 8 + assert response["success"] is False