diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index fc571ad..6e28673 100644 --- a/custom_components/pyscript/__init__.py +++ b/custom_components/pyscript/__init__.py @@ -7,7 +7,7 @@ import voluptuous as vol -from homeassistant.config import async_hass_config_yaml, async_process_component_config +from homeassistant.config import async_hass_config_yaml from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, @@ -17,7 +17,7 @@ ) from homeassistant.exceptions import HomeAssistantError import homeassistant.helpers.config_validation as cv -from homeassistant.loader import async_get_integration, bind_hass +from homeassistant.loader import bind_hass from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN, FOLDER, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START from .eval import AstEval @@ -81,11 +81,14 @@ async def reload_scripts_handler(call): _LOGGER.error(err) return - integration = await async_get_integration(hass, DOMAIN) + config = PYSCRIPT_SCHEMA(conf.get(DOMAIN, {})) - config = await async_process_component_config(hass, conf, integration) + # If data in config doesn't match config entry, trigger a config import + # so that the config entry can get updated + if config != config_entry.data: + await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=config) - State.set_pyscript_config(config.get(DOMAIN, {})) + State.set_pyscript_config(config_entry.data) ctx_delete = {} for global_ctx_name, global_ctx in GlobalContextMgr.items(): @@ -97,7 +100,7 @@ async def reload_scripts_handler(call): for global_ctx_name, global_ctx in ctx_delete.items(): await GlobalContextMgr.delete(global_ctx_name) - await load_scripts(hass, config) + await load_scripts(hass, config_entry.data) for global_ctx_name, global_ctx in GlobalContextMgr.items(): idx = global_ctx_name.find(".") @@ -188,14 +191,14 @@ async def async_unload_entry(hass, config_entry): @bind_hass -async def load_scripts(hass, config): +async def load_scripts(hass, data): """Load all python scripts in FOLDER.""" pyscript_dir = hass.config.path(FOLDER) - def glob_files(load_paths, config): + def glob_files(load_paths, data): source_files = [] - apps_config = config.get(DOMAIN, {}).get("apps", None) + apps_config = data.get("apps", None) for path, match, check_config in load_paths: for this_path in sorted(glob.glob(os.path.join(pyscript_dir, path, match))): rel_import_path = None @@ -227,7 +230,7 @@ def glob_files(load_paths, config): ["", "*.py", False], ] - source_files = await hass.async_add_executor_job(glob_files, load_paths, config) + source_files = await hass.async_add_executor_job(glob_files, load_paths, data) for global_ctx_name, source_file, rel_import_path, fq_mod_name in source_files: global_ctx = GlobalContext( global_ctx_name, diff --git a/custom_components/pyscript/config_flow.py b/custom_components/pyscript/config_flow.py index fbe113e..043eb30 100644 --- a/custom_components/pyscript/config_flow.py +++ b/custom_components/pyscript/config_flow.py @@ -1,9 +1,11 @@ """Config flow for pyscript.""" +import json from typing import Any, Dict import voluptuous as vol from homeassistant import config_entries +from homeassistant.config_entries import SOURCE_IMPORT from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN @@ -18,7 +20,7 @@ class PyscriptConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH - async def async_step_user(self, user_input: Dict[str, Any] = None) -> None: + async def async_step_user(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]: """Handle a flow initialized by the user.""" if user_input is not None: if len(self.hass.config_entries.async_entries(DOMAIN)) > 0: @@ -29,20 +31,35 @@ async def async_step_user(self, user_input: Dict[str, Any] = None) -> None: return self.async_show_form(step_id="user", data_schema=PYSCRIPT_SCHEMA) - async def async_step_import(self, import_config: Dict[str, Any] = None) -> None: + async def async_step_import(self, import_config: Dict[str, Any] = None) -> Dict[str, Any]: """Import a config entry from configuration.yaml.""" + # Convert OrderedDict to dict + import_config = json.loads(json.dumps(import_config)) + # Check if import config entry matches any existing config entries # so we can update it if necessary entries = self.hass.config_entries.async_entries(DOMAIN) if entries: entry = entries[0] - if entry.data.get(CONF_ALLOW_ALL_IMPORTS, False) != import_config.get( - CONF_ALLOW_ALL_IMPORTS, False - ): - updated_data = entry.data.copy() - updated_data[CONF_ALLOW_ALL_IMPORTS] = import_config.get(CONF_ALLOW_ALL_IMPORTS, False) + updated_data = entry.data.copy() + + # Update values for all keys, excluding `allow_all_imports` for entries + # set up through the UI. + for k, v in import_config.items(): + if entry.source == SOURCE_IMPORT or k != CONF_ALLOW_ALL_IMPORTS: + updated_data[k] = v + + # Remove values for all keys in entry.data that are not in the imported config, + # excluding `allow_all_imports` for entries set up through the UI. + for key in entry.data: + if ( + entry.source == SOURCE_IMPORT or key != CONF_ALLOW_ALL_IMPORTS + ) and key not in import_config: + updated_data.pop(key) + + # Update and reload entry if data needs to be updated + if updated_data != entry.data: self.hass.config_entries.async_update_entry(entry=entry, data=updated_data) - await self.hass.config_entries.async_reload(entry.entry_id) return self.async_abort(reason="updated_entry") return self.async_abort(reason="already_configured_service") diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py index 7f76f35..9ebffee 100644 --- a/tests/test_config_flow.py +++ b/tests/test_config_flow.py @@ -75,8 +75,8 @@ async def test_import_flow(hass, pyscript_bypass_setup): assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY -async def test_import_flow_update_entry(hass): - """Test import config flow updates existing entry.""" +async def test_import_flow_update_allow_all_imports(hass): + """Test import config flow updates existing entry when `allow_all_imports` has changed.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({}) ) @@ -91,6 +91,36 @@ async def test_import_flow_update_entry(hass): assert result["reason"] == "updated_entry" +async def test_import_flow_update_apps_from_none(hass): + """Test import config flow updates existing entry when `apps` has changed from None to something.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({}) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={"apps": {"test_app": {"param": 1}}} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "updated_entry" + + +async def test_import_flow_update_apps_to_none(hass): + """Test import config flow updates existing entry when `apps` has changed from something to None.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({"apps": {"test_app": {"param": 1}}}) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data={}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "updated_entry" + + async def test_import_flow_no_update(hass): """Test import config flow doesn't update existing entry when data is same.""" result = await hass.config_entries.flow.async_init( @@ -105,3 +135,42 @@ async def test_import_flow_no_update(hass): assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["reason"] == "already_configured_service" + + +async def test_import_flow_update_user(hass): + """Test import config flow update excludes `allow_all_imports` from being updated when updated entry was a user entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True}) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={"apps": {"test_app": {"param": 1}}} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "updated_entry" + + hass.config_entries.async_entries(DOMAIN)[0].data == { + CONF_ALLOW_ALL_IMPORTS: True, + "apps": {"test_app": {"param": 1}}, + } + + +async def test_import_flow_update_import(hass): + """Test import config flow update includes `allow_all_imports` in update when updated entry was imported entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True}) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data={"apps": {"test_app": {"param": 1}}} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "updated_entry" + + hass.config_entries.async_entries(DOMAIN)[0].data == {"apps": {"test_app": {"param": 1}}} diff --git a/tests/test_decorator_errors.py b/tests/test_decorator_errors.py index a529c96..4fce5b5 100644 --- a/tests/test_decorator_errors.py +++ b/tests/test_decorator_errors.py @@ -2,13 +2,11 @@ from ast import literal_eval import asyncio from datetime import datetime as dt -import pathlib from custom_components.pyscript.const import DOMAIN import custom_components.pyscript.trigger as trigger from pytest_homeassistant.async_mock import mock_open, patch -from homeassistant import loader from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED from homeassistant.setup import async_setup_component @@ -18,18 +16,10 @@ async def setup_script(hass, notify_q, now, source): scripts = [ "/some/config/dir/pyscripts/hello.py", ] - integration = loader.Integration( - hass, - "custom_components.pyscript", - pathlib.Path("custom_components/pyscript"), - {"name": "pyscript", "dependencies": [], "requirements": [], "domain": "automation"}, - ) - with patch("homeassistant.loader.async_get_integration", return_value=integration), patch( - "custom_components.pyscript.os.path.isdir", return_value=True - ), patch("custom_components.pyscript.glob.iglob", return_value=scripts), patch( - "custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True, - ), patch( + with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( + "custom_components.pyscript.glob.iglob", return_value=scripts + ), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,), patch( "custom_components.pyscript.trigger.dt_now", return_value=now ): assert await async_setup_component(hass, "pyscript", {DOMAIN: {}}) diff --git a/tests/test_function.py b/tests/test_function.py index e89072f..a24a031 100644 --- a/tests/test_function.py +++ b/tests/test_function.py @@ -2,7 +2,6 @@ from ast import literal_eval import asyncio from datetime import datetime as dt -import pathlib import time from custom_components.pyscript.const import DOMAIN @@ -11,7 +10,6 @@ import pytest from pytest_homeassistant.async_mock import MagicMock, Mock, mock_open, patch -from homeassistant import loader from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED from homeassistant.setup import async_setup_component @@ -104,18 +102,10 @@ async def setup_script(hass, notify_q, now, source): scripts = [ "/some/config/dir/pyscripts/hello.py", ] - integration = loader.Integration( - hass, - "custom_components.pyscript", - pathlib.Path("custom_components/pyscript"), - {"name": "pyscript", "dependencies": [], "requirements": [], "domain": "automation"}, - ) - with patch("homeassistant.loader.async_get_integration", return_value=integration), patch( - "custom_components.pyscript.os.path.isdir", return_value=True - ), patch("custom_components.pyscript.glob.iglob", return_value=scripts), patch( - "custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True, - ), patch( + with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( + "custom_components.pyscript.glob.iglob", return_value=scripts + ), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,), patch( "custom_components.pyscript.trigger.dt_now", return_value=now ): assert await async_setup_component(hass, "pyscript", {DOMAIN: {}}) diff --git a/tests/test_init.py b/tests/test_init.py index d897d08..eadc6ec 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -24,18 +24,10 @@ async def setup_script(hass, notify_q, now, source): scripts = [ "/some/config/dir/pyscript/hello.py", ] - integration = loader.Integration( - hass, - "custom_components.pyscript", - pathlib.Path("custom_components/pyscript"), - {"name": "pyscript", "dependencies": [], "requirements": [], "domain": "automation"}, - ) - with patch("homeassistant.loader.async_get_integration", return_value=integration), patch( - "custom_components.pyscript.os.path.isdir", return_value=True - ), patch("custom_components.pyscript.glob.iglob", return_value=scripts), patch( - "custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True, - ), patch( + with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( + "custom_components.pyscript.glob.iglob", return_value=scripts + ), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,), patch( "custom_components.pyscript.trigger.dt_now", return_value=now ), patch( "homeassistant.config.load_yaml_config_file", return_value={} @@ -67,16 +59,9 @@ async def wait_until_done(notify_q): async def test_setup_makedirs_on_no_dir(hass, caplog): """Test setup calls os.makedirs when no dir found.""" - integration = loader.Integration( - hass, - "custom_components.pyscript", - pathlib.Path("custom_components/pyscript"), - {"name": "pyscript", "dependencies": [], "requirements": [], "domain": "automation"}, - ) - - with patch("homeassistant.loader.async_get_integration", return_value=integration), patch( - "custom_components.pyscript.os.path.isdir", return_value=False - ), patch("custom_components.pyscript.os.makedirs") as makedirs_call: + with patch("custom_components.pyscript.os.path.isdir", return_value=False), patch( + "custom_components.pyscript.os.makedirs" + ) as makedirs_call: res = await async_setup_component(hass, "pyscript", {DOMAIN: {}}) assert res @@ -237,7 +222,7 @@ def func_yaml_doc_string(param2=None, param3=None): {"name": "pyscript", "dependencies": [], "requirements": [], "domain": "automation"}, ) - with patch("homeassistant.loader.async_get_integration", return_value=integration), patch( + with patch( "homeassistant.loader.async_get_custom_components", return_value={"pyscript": integration}, ): descriptions = await async_get_all_descriptions(hass) @@ -442,16 +427,10 @@ def func5(var_name=None, value=None): scripts = [ "/some/config/dir/pyscript/hello.py", ] - integration = loader.Integration( - hass, - "custom_components.pyscript", - pathlib.Path("custom_components/pyscript"), - {"name": "pyscript", "dependencies": [], "requirements": [], "domain": "automation"}, - ) - - with patch("homeassistant.loader.async_get_integration", return_value=integration), patch( - "custom_components.pyscript.os.path.isdir", return_value=True - ), patch("custom_components.pyscript.glob.iglob", return_value=scripts), patch( + + with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( + "custom_components.pyscript.glob.iglob", return_value=scripts + ), patch( "custom_components.pyscript.global_ctx.open", mock_open(read_data=next_source), create=True, ), patch( "custom_components.pyscript.trigger.dt_now", return_value=now diff --git a/tests/test_jupyter.py b/tests/test_jupyter.py index 148de80..94f6996 100644 --- a/tests/test_jupyter.py +++ b/tests/test_jupyter.py @@ -6,7 +6,6 @@ import hashlib import hmac import json -import pathlib import uuid from custom_components.pyscript.const import DOMAIN @@ -14,7 +13,6 @@ import custom_components.pyscript.trigger as trigger from pytest_homeassistant.async_mock import mock_open, patch -from homeassistant import loader from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.setup import async_setup_component @@ -112,18 +110,10 @@ async def setup_script(hass, now, source, no_connect=False): scripts = [ "/some/config/dir/pyscripts/hello.py", ] - integration = loader.Integration( - hass, - "custom_components.pyscript", - pathlib.Path("custom_components/pyscript"), - {"name": "pyscript", "dependencies": [], "requirements": [], "domain": "automation"}, - ) - with patch("homeassistant.loader.async_get_integration", return_value=integration,), patch( - "custom_components.pyscript.os.path.isdir", return_value=True - ), patch("custom_components.pyscript.glob.iglob", return_value=scripts), patch( - "custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True, - ), patch( + with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( + "custom_components.pyscript.glob.iglob", return_value=scripts + ), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,), patch( "custom_components.pyscript.trigger.dt_now", return_value=now ): assert await async_setup_component(hass, "pyscript", {DOMAIN: {}}) diff --git a/tests/test_unique.py b/tests/test_unique.py index dda91bc..f702354 100644 --- a/tests/test_unique.py +++ b/tests/test_unique.py @@ -2,13 +2,11 @@ from ast import literal_eval import asyncio from datetime import datetime as dt -import pathlib from custom_components.pyscript.const import DOMAIN import custom_components.pyscript.trigger as trigger from pytest_homeassistant.async_mock import mock_open, patch -from homeassistant import loader from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED from homeassistant.setup import async_setup_component @@ -18,18 +16,10 @@ async def setup_script(hass, notify_q, now, source): scripts = [ "/some/config/dir/pyscripts/hello.py", ] - integration = loader.Integration( - hass, - "custom_components.pyscript", - pathlib.Path("custom_components/pyscript"), - {"name": "pyscript", "dependencies": [], "requirements": [], "domain": "automation"}, - ) - with patch("homeassistant.loader.async_get_integration", return_value=integration), patch( - "custom_components.pyscript.os.path.isdir", return_value=True - ), patch("custom_components.pyscript.glob.iglob", return_value=scripts), patch( - "custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True, - ), patch( + with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch( + "custom_components.pyscript.glob.iglob", return_value=scripts + ), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,), patch( "custom_components.pyscript.trigger.dt_now", return_value=now ): assert await async_setup_component(hass, "pyscript", {DOMAIN: {}})