Skip to content

Support reloading allow all imports through reload service and config entry options UI #42

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion custom_components/pyscript/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ async def async_setup_entry(hass, config_entry):
await hass.async_add_executor_job(os.makedirs, pyscript_folder)

hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][CONF_ALLOW_ALL_IMPORTS] = config_entry.data.get(CONF_ALLOW_ALL_IMPORTS)
hass.data[DOMAIN] = config_entry

State.set_pyscript_config(config_entry.data)

Expand Down
64 changes: 63 additions & 1 deletion custom_components/pyscript/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
import voluptuous as vol

from homeassistant import config_entries
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.core import callback

from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN

Expand All @@ -14,12 +15,73 @@
)


class PyscriptOptionsConfigFlow(config_entries.OptionsFlow):
"""Handle a pyscript options flow."""

def __init__(self, config_entry: ConfigEntry) -> None:
"""Initialize pyscript options flow."""
self.config_entry = config_entry
self._show_form = False

async def async_step_init(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]:
"""Manage the pyscript options."""
if self.config_entry.source == SOURCE_IMPORT:
self._show_form = True
return await self.async_step_no_ui_configuration_allowed()

if user_input is None:
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Optional(
CONF_ALLOW_ALL_IMPORTS, default=self.config_entry.data[CONF_ALLOW_ALL_IMPORTS],
): bool
},
extra=vol.ALLOW_EXTRA,
),
)

if user_input[CONF_ALLOW_ALL_IMPORTS] != self.config_entry.data[CONF_ALLOW_ALL_IMPORTS]:
updated_data = self.config_entry.data.copy()
updated_data.update(user_input)
self.hass.config_entries.async_update_entry(entry=self.config_entry, data=updated_data)
return self.async_create_entry(title="", data={})

self._show_form = True
return await self.async_step_no_update()

async def async_step_no_ui_configuration_allowed(
self, user_input: Dict[str, Any] = None
) -> Dict[str, Any]:
"""Tell user no UI configuration is allowed."""
if self._show_form:
self._show_form = False
return self.async_show_form(step_id="no_ui_configuration_allowed", data_schema=vol.Schema({}))

return self.async_create_entry(title="", data={})

async def async_step_no_update(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]:
"""Tell user no update to process."""
if self._show_form:
self._show_form = False
return self.async_show_form(step_id="no_update", data_schema=vol.Schema({}))

return self.async_create_entry(title="", data={})


class PyscriptConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a pyscript config flow."""

VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH

@staticmethod
@callback
def async_get_options_flow(config_entry: ConfigEntry) -> PyscriptOptionsConfigFlow:
"""Get the options flow for this handler."""
return PyscriptOptionsConfigFlow(config_entry)

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:
Expand Down
14 changes: 10 additions & 4 deletions custom_components/pyscript/eval.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from homeassistant.const import SERVICE_RELOAD
from homeassistant.helpers.service import async_set_service_schema

from .const import ALLOWED_IMPORTS, DOMAIN, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START
from .const import ALLOWED_IMPORTS, CONF_ALLOW_ALL_IMPORTS, DOMAIN, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START
from .function import Function
from .state import State

Expand Down Expand Up @@ -721,7 +721,7 @@ def __init__(self, name, global_ctx, logger_name=None):
self.logger_handlers = set()
self.logger = None
self.set_logger_name(logger_name if logger_name is not None else self.name)
self.allow_all_imports = Function.hass.data.get(DOMAIN, {}).get("allow_all_imports", False)
self.config_entry = Function.hass.data.get(DOMAIN, {})

async def ast_not_implemented(self, arg, *args):
"""Raise NotImplementedError exception for unimplemented AST types."""
Expand Down Expand Up @@ -769,7 +769,10 @@ async def ast_import(self, arg):
self.exception_long = error_ctx.exception_long
raise self.exception_obj
if not mod:
if not self.allow_all_imports and imp.name not in ALLOWED_IMPORTS:
if (
not self.config_entry.data.get(CONF_ALLOW_ALL_IMPORTS, False)
and imp.name not in ALLOWED_IMPORTS
):
raise ModuleNotFoundError(f"import of {imp.name} not allowed")
if imp.name not in sys.modules:
mod = await Function.hass.async_add_executor_job(importlib.import_module, imp.name)
Expand Down Expand Up @@ -799,7 +802,10 @@ async def ast_importfrom(self, arg):
self.exception_long = error_ctx.exception_long
raise self.exception_obj
if not mod:
if not self.allow_all_imports and arg.module not in ALLOWED_IMPORTS:
if (
not self.config_entry.data.get(CONF_ALLOW_ALL_IMPORTS, False)
and arg.module not in ALLOWED_IMPORTS
):
raise ModuleNotFoundError(f"import from {arg.module} not allowed")
if arg.module not in sys.modules:
mod = await Function.hass.async_add_executor_job(importlib.import_module, arg.module)
Expand Down
20 changes: 19 additions & 1 deletion custom_components/pyscript/strings.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,23 @@
"single_instance_allowed": "Already configured. Only a single configuration possible.",
"updated_entry": "This entry has already been setup but the configuration has been updated."
}
},
"options": {
"step": {
"init": {
"title": "Update pyscript configuration",
"data": {
"allow_all_imports": "Allow All Imports?"
}
},
"no_ui_configuration_allowed": {
"title": "No UI configuration allowed",
"description": "This entry was created via `configuration.yaml`, so all configuration parameters must be updated there. The [`pyscript.reload`](developer-tools/service) service will allow you to apply the changes you make to `configuration.yaml` without restarting your Home Assistant instance."
},
"no_update": {
"title": "No update needed",
"description": "There is nothing to update."
}
}
}
}
}
20 changes: 19 additions & 1 deletion custom_components/pyscript/translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,23 @@
"single_instance_allowed": "Already configured. Only a single configuration possible.",
"updated_entry": "This entry has already been setup but the configuration has been updated."
}
},
"options": {
"step": {
"init": {
"title": "Update pyscript configuration",
"data": {
"allow_all_imports": "Allow All Imports?"
}
},
"no_ui_configuration_allowed": {
"title": "No UI configuration allowed",
"description": "This entry was created via `configuration.yaml`, so all configuration parameters must be updated there. The [`pyscript.reload`](developer-tools/service) service will allow you to apply the changes you make to `configuration.yaml` without restarting your Home Assistant instance."
},
"no_update": {
"title": "No update needed",
"description": "There is nothing to update."
}
}
}
}
}
72 changes: 72 additions & 0 deletions tests/test_config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -174,3 +174,75 @@ async def test_import_flow_update_import(hass):
assert result["reason"] == "updated_entry"

assert hass.config_entries.async_entries(DOMAIN)[0].data == {"apps": {"test_app": {"param": 1}}}


async def test_options_flow_import(hass):
"""Test options flow aborts because configuration needs to be managed via configuration.yaml."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True})
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry = result["result"]

result = await hass.config_entries.options.async_init(entry.entry_id, data=None)

assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "no_ui_configuration_allowed"

result = await hass.config_entries.options.async_configure(result["flow_id"], user_input=None)

assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == ""


async def test_options_flow_user_change(hass):
"""Test options flow updates config entry when options change."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True})
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry = result["result"]

result = await hass.config_entries.options.async_init(entry.entry_id)

assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"

result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: False}
)
await hass.async_block_till_done()

assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == ""

assert entry.data[CONF_ALLOW_ALL_IMPORTS] is False


async def test_options_flow_user_no_change(hass):
"""Test options flow aborts when options don't change."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True})
)
await hass.async_block_till_done()
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
entry = result["result"]

result = await hass.config_entries.options.async_init(entry.entry_id)

assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"

result = await hass.config_entries.options.async_configure(
result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True}
)

assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "no_update"

result = await hass.config_entries.options.async_configure(result["flow_id"], user_input=None)

assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == ""
4 changes: 4 additions & 0 deletions tests/test_unit_eval.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
"""Unit tests for Python interpreter."""

from custom_components.pyscript.const import CONF_ALLOW_ALL_IMPORTS, DOMAIN
from custom_components.pyscript.eval import AstEval
from custom_components.pyscript.function import Function
from custom_components.pyscript.global_ctx import GlobalContext, GlobalContextMgr
from custom_components.pyscript.state import State
from pytest_homeassistant_custom_component.common import MockConfigEntry

evalTests = [
["1", 1],
Expand Down Expand Up @@ -882,6 +884,7 @@ async def run_one_test(test_data):

async def test_eval(hass):
"""Test interpreter."""
hass.data[DOMAIN] = MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: False})
Function.init(hass)
State.init(hass)
State.register_functions()
Expand Down Expand Up @@ -1062,6 +1065,7 @@ async def run_one_test_exception(test_data):

async def test_eval_exceptions(hass):
"""Test interpreter exceptions."""
hass.data[DOMAIN] = MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: False})
Function.init(hass)
State.init(hass)
State.register_functions()
Expand Down