diff --git a/.gitignore b/.gitignore index 5999001..1043c81 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ icon venv hass-custom-pyscript.zip .coverage +.vscode .*.swp diff --git a/README.md b/README.md index aac1dfc..3d8660a 100644 --- a/README.md +++ b/README.md @@ -59,13 +59,11 @@ this [README](https://github.com/craigbarratt/hass-pyscript-jupyter/blob/master/ ## Configuration -* Add `pyscript:` to `/configuration.yaml`; pyscript has one optional -configuration parameter that allows any python package to be imported if set, eg: +* Go to the Integrations menu in the Home Assistant Configuration UI and add `Pyscript Python scripting` from there, or add `pyscript:` to `/configuration.yaml`; pyscript has one optional configuration parameter that allows any python package to be imported if set, eg: ```yaml pyscript: allow_all_imports: true ``` -* Create the folder `/pyscript` * Add files with a suffix of `.py` in the folder `/pyscript`. * Restart HASS. * Whenever you change a script file, make a `reload` service call to `pyscript`. diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index 86c9f2d..0ca6ca9 100644 --- a/custom_components/pyscript/__init__.py +++ b/custom_components/pyscript/__init__.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.config import async_hass_config_yaml, async_process_component_config +from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.const import ( EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP, @@ -18,7 +19,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.loader import async_get_integration, bind_hass -from .const import DOMAIN, FOLDER, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START +from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN, FOLDER, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START from .eval import AstEval from .event import Event from .function import Function @@ -29,20 +30,27 @@ _LOGGER = logging.getLogger(LOGGER_PATH) -CONF_ALLOW_ALL_IMPORTS = "allow_all_imports" - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema( - {vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): cv.boolean}, extra=vol.ALLOW_EXTRA, - ) - }, - extra=vol.ALLOW_EXTRA, +PYSCRIPT_SCHEMA = vol.Schema( + {vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): cv.boolean}, extra=vol.ALLOW_EXTRA, ) +CONFIG_SCHEMA = vol.Schema({DOMAIN: PYSCRIPT_SCHEMA}, extra=vol.ALLOW_EXTRA) + async def async_setup(hass, config): - """Initialize the pyscript component.""" + """Component setup, run import config flow for each entry in config.""" + if DOMAIN in config: + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config[DOMAIN] + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Initialize the pyscript config entry.""" Function.init(hass) Event.init(hass) TrigTime.init(hass) @@ -52,19 +60,16 @@ async def async_setup(hass, config): pyscript_folder = hass.config.path(FOLDER) - def check_isdir(path): - return os.path.isdir(path) - - if not await hass.async_add_executor_job(check_isdir, pyscript_folder): - _LOGGER.error("Folder %s not found in configuration folder", FOLDER) - return False + if not await hass.async_add_executor_job(os.path.isdir, pyscript_folder): + _LOGGER.debug("Folder %s not found in configuration folder, creating it", FOLDER) + await hass.async_add_executor_job(os.makedirs, pyscript_folder) hass.data.setdefault(DOMAIN, {}) - hass.data[DOMAIN]["allow_all_imports"] = config[DOMAIN].get(CONF_ALLOW_ALL_IMPORTS) + hass.data[DOMAIN][CONF_ALLOW_ALL_IMPORTS] = config_entry.data.get(CONF_ALLOW_ALL_IMPORTS) - State.set_pyscript_config(config.get(DOMAIN, {})) + State.set_pyscript_config(config_entry.data) - await load_scripts(hass, config) + await load_scripts(hass, config_entry.data) async def reload_scripts_handler(call): """Handle reload service calls.""" @@ -176,6 +181,12 @@ async def stop_triggers(event): return True +async def async_unload_entry(hass, config_entry): + """Unload a config entry.""" + hass.data.pop(DOMAIN) + return True + + @bind_hass async def load_scripts(hass, config): """Load all python scripts in FOLDER.""" diff --git a/custom_components/pyscript/config_flow.py b/custom_components/pyscript/config_flow.py new file mode 100644 index 0000000..d91b636 --- /dev/null +++ b/custom_components/pyscript/config_flow.py @@ -0,0 +1,48 @@ +"""Config flow for pyscript.""" +import voluptuous as vol + +from homeassistant import config_entries + +from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN + +PYSCRIPT_SCHEMA = vol.Schema( + {vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): bool}, extra=vol.ALLOW_EXTRA, +) + + +class PyscriptConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a pyscript config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH + + async def async_step_user(self, user_input): + """Handle a flow initialized by the user.""" + if user_input is not None: + if len(self.hass.config_entries.async_entries(DOMAIN)) > 0: + return self.async_abort(reason="single_instance_allowed") + + await self.async_set_unique_id(DOMAIN) + return self.async_create_entry(title=DOMAIN, data=user_input) + + return self.async_show_form(step_id="user", data_schema=PYSCRIPT_SCHEMA) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + # 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) + 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") + + return await self.async_step_user(user_input=import_config) diff --git a/custom_components/pyscript/const.py b/custom_components/pyscript/const.py index 1bd61ee..cc9c7d7 100644 --- a/custom_components/pyscript/const.py +++ b/custom_components/pyscript/const.py @@ -4,6 +4,8 @@ FOLDER = "pyscript" +CONF_ALLOW_ALL_IMPORTS = "allow_all_imports" + SERVICE_JUPYTER_KERNEL_START = "jupyter_kernel_start" LOGGER_PATH = "custom_components.pyscript" diff --git a/custom_components/pyscript/manifest.json b/custom_components/pyscript/manifest.json index f991cd5..b011e92 100644 --- a/custom_components/pyscript/manifest.json +++ b/custom_components/pyscript/manifest.json @@ -1,7 +1,7 @@ { "domain": "pyscript", "name": "Pyscript Python scripting", - "config_flow": false, + "config_flow": true, "documentation": "https://github.com/custom-components/pyscript", "issue_tracker": "https://github.com/custom-components/pyscript/issues", "requirements": ["croniter==0.3.34"], diff --git a/custom_components/pyscript/strings.json b/custom_components/pyscript/strings.json new file mode 100644 index 0000000..3f13345 --- /dev/null +++ b/custom_components/pyscript/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "title": "pyscript", + "description": "Once you have created an entry, refer to the [docs](https://hacs-pyscript.readthedocs.io/en/latest/) to learn how to create scripts and functions.", + "data": { + "allow_all_imports": "Allow All Imports?" + } + } + }, + "abort": { + "already_configured_service": "[%key:common::config_flow::abort::already_configured_service%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "updated_entry": "This entry has already been setup but the configuration has been updated." + } + } +} \ No newline at end of file diff --git a/custom_components/pyscript/translations/en.json b/custom_components/pyscript/translations/en.json new file mode 100644 index 0000000..3f13345 --- /dev/null +++ b/custom_components/pyscript/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "step": { + "user": { + "title": "pyscript", + "description": "Once you have created an entry, refer to the [docs](https://hacs-pyscript.readthedocs.io/en/latest/) to learn how to create scripts and functions.", + "data": { + "allow_all_imports": "Allow All Imports?" + } + } + }, + "abort": { + "already_configured_service": "[%key:common::config_flow::abort::already_configured_service%]", + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]", + "updated_entry": "This entry has already been setup but the configuration has been updated." + } + } +} \ No newline at end of file diff --git a/docs/configuration.rst b/docs/configuration.rst index b68f2ad..f98154b 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -1,16 +1,16 @@ Configuration ============= -- Add ``pyscript:`` to ``/configuration.yaml``; pyscript has - one optional configuration parameter that allows any python package - to be imported if set, eg: +- Go to the Integrations menu in the Home Assistant Configuration UI and add + ``Pyscript Python scripting`` from there, or add ``pyscript:`` to + ``/configuration.yaml``; pyscript has one optional configuration + parameter that allows any python package to be imported if set, eg: .. code:: yaml pyscript: allow_all_imports: true -- Create the folder ``/pyscript`` - Add files with a suffix of ``.py`` in the folder ``/pyscript``. - Restart HASS. - Whenever you change a script file, make a ``reload`` service call to ``pyscript``. diff --git a/info.md b/info.md index 00de20e..55e81f9 100644 --- a/info.md +++ b/info.md @@ -23,8 +23,7 @@ See the documentation if you want to install pyscript manually. ## Configuration -* Add `pyscript:` to `/configuration.yaml`; there is one optional parameter (see docs) -* Create the folder `/pyscript` +* Go to the Integrations menu in the Home Assistant Configuration UI and add `Pyscript Python scripting` from there, or add `pyscript:` to `/configuration.yaml`; there is one optional parameter (see docs) * Add files with a suffix of `.py` in the folder `/pyscript`. * Whenever you change a script file, make a `reload` service call to `pyscript`. * Watch the HASS log for `pyscript` errors and logger output from your scripts. diff --git a/tests/test_config_flow.py b/tests/test_config_flow.py new file mode 100644 index 0000000..7f76f35 --- /dev/null +++ b/tests/test_config_flow.py @@ -0,0 +1,107 @@ +"""Tests for pyscript config flow.""" +import logging + +from custom_components.pyscript import PYSCRIPT_SCHEMA +from custom_components.pyscript.const import CONF_ALLOW_ALL_IMPORTS, DOMAIN +import pytest +from pytest_homeassistant.async_mock import patch + +from homeassistant import data_entry_flow +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER + +_LOGGER = logging.getLogger(__name__) + + +@pytest.fixture(name="pyscript_bypass_setup", autouse=True) +def pyscript_bypass_setup_fixture(): + """Mock component setup.""" + with patch("custom_components.pyscript.async_setup_entry", return_value=True): + yield + + +async def test_user_flow_minimum_fields(hass): + """Test user config flow with minimum fields.""" + # test form shows + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], user_input={}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert CONF_ALLOW_ALL_IMPORTS in result["data"] + assert not result["data"][CONF_ALLOW_ALL_IMPORTS] + + +async def test_user_flow_all_fields(hass): + """Test user config flow with all fields.""" + # test form shows + result = await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_USER}) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert CONF_ALLOW_ALL_IMPORTS in result["data"] + assert result["data"][CONF_ALLOW_ALL_IMPORTS] + + +async def test_user_already_configured(hass): + """Test service is already configured during user setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={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_USER}, data={CONF_ALLOW_ALL_IMPORTS: True} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_import_flow(hass, pyscript_bypass_setup): + """Test import config flow works.""" + 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 + + +async def test_import_flow_update_entry(hass): + """Test import config flow updates existing entry.""" + 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={CONF_ALLOW_ALL_IMPORTS: True} + ) + + 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( + 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=PYSCRIPT_SCHEMA({}) + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured_service" diff --git a/tests/test_init.py b/tests/test_init.py index 6805bef..d897d08 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -65,8 +65,8 @@ async def wait_until_done(notify_q): return await asyncio.wait_for(notify_q.get(), timeout=4) -async def test_setup_fails_on_no_dir(hass, caplog): - """Test we fail setup when no dir found.""" +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", @@ -76,11 +76,11 @@ async def test_setup_fails_on_no_dir(hass, caplog): 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: res = await async_setup_component(hass, "pyscript", {DOMAIN: {}}) - assert not res - assert "Folder pyscript not found in configuration folder" in caplog.text + assert res + assert makedirs_call.called async def test_service_exists(hass, caplog):