Skip to content

Commit 8aacdc6

Browse files
authored
Merge pull request #42 from raman325/reload_allow_all_imports
Support reloading allow all imports through reload service and config entry options UI
2 parents 9e13813 + b22e008 commit 8aacdc6

File tree

7 files changed

+188
-8
lines changed

7 files changed

+188
-8
lines changed

custom_components/pyscript/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ async def async_setup_entry(hass, config_entry):
6565
await hass.async_add_executor_job(os.makedirs, pyscript_folder)
6666

6767
hass.data.setdefault(DOMAIN, {})
68-
hass.data[DOMAIN][CONF_ALLOW_ALL_IMPORTS] = config_entry.data.get(CONF_ALLOW_ALL_IMPORTS)
68+
hass.data[DOMAIN] = config_entry
6969

7070
State.set_pyscript_config(config_entry.data)
7171

custom_components/pyscript/config_flow.py

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
import voluptuous as vol
66

77
from homeassistant import config_entries
8-
from homeassistant.config_entries import SOURCE_IMPORT
8+
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
9+
from homeassistant.core import callback
910

1011
from .const import CONF_ALLOW_ALL_IMPORTS, DOMAIN
1112

@@ -14,12 +15,73 @@
1415
)
1516

1617

18+
class PyscriptOptionsConfigFlow(config_entries.OptionsFlow):
19+
"""Handle a pyscript options flow."""
20+
21+
def __init__(self, config_entry: ConfigEntry) -> None:
22+
"""Initialize pyscript options flow."""
23+
self.config_entry = config_entry
24+
self._show_form = False
25+
26+
async def async_step_init(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]:
27+
"""Manage the pyscript options."""
28+
if self.config_entry.source == SOURCE_IMPORT:
29+
self._show_form = True
30+
return await self.async_step_no_ui_configuration_allowed()
31+
32+
if user_input is None:
33+
return self.async_show_form(
34+
step_id="init",
35+
data_schema=vol.Schema(
36+
{
37+
vol.Optional(
38+
CONF_ALLOW_ALL_IMPORTS, default=self.config_entry.data[CONF_ALLOW_ALL_IMPORTS],
39+
): bool
40+
},
41+
extra=vol.ALLOW_EXTRA,
42+
),
43+
)
44+
45+
if user_input[CONF_ALLOW_ALL_IMPORTS] != self.config_entry.data[CONF_ALLOW_ALL_IMPORTS]:
46+
updated_data = self.config_entry.data.copy()
47+
updated_data.update(user_input)
48+
self.hass.config_entries.async_update_entry(entry=self.config_entry, data=updated_data)
49+
return self.async_create_entry(title="", data={})
50+
51+
self._show_form = True
52+
return await self.async_step_no_update()
53+
54+
async def async_step_no_ui_configuration_allowed(
55+
self, user_input: Dict[str, Any] = None
56+
) -> Dict[str, Any]:
57+
"""Tell user no UI configuration is allowed."""
58+
if self._show_form:
59+
self._show_form = False
60+
return self.async_show_form(step_id="no_ui_configuration_allowed", data_schema=vol.Schema({}))
61+
62+
return self.async_create_entry(title="", data={})
63+
64+
async def async_step_no_update(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]:
65+
"""Tell user no update to process."""
66+
if self._show_form:
67+
self._show_form = False
68+
return self.async_show_form(step_id="no_update", data_schema=vol.Schema({}))
69+
70+
return self.async_create_entry(title="", data={})
71+
72+
1773
class PyscriptConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
1874
"""Handle a pyscript config flow."""
1975

2076
VERSION = 1
2177
CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_PUSH
2278

79+
@staticmethod
80+
@callback
81+
def async_get_options_flow(config_entry: ConfigEntry) -> PyscriptOptionsConfigFlow:
82+
"""Get the options flow for this handler."""
83+
return PyscriptOptionsConfigFlow(config_entry)
84+
2385
async def async_step_user(self, user_input: Dict[str, Any] = None) -> Dict[str, Any]:
2486
"""Handle a flow initialized by the user."""
2587
if user_input is not None:

custom_components/pyscript/eval.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from homeassistant.const import SERVICE_RELOAD
1717
from homeassistant.helpers.service import async_set_service_schema
1818

19-
from .const import ALLOWED_IMPORTS, DOMAIN, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START
19+
from .const import ALLOWED_IMPORTS, CONF_ALLOW_ALL_IMPORTS, DOMAIN, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START
2020
from .function import Function
2121
from .state import State
2222

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

726726
async def ast_not_implemented(self, arg, *args):
727727
"""Raise NotImplementedError exception for unimplemented AST types."""
@@ -769,7 +769,10 @@ async def ast_import(self, arg):
769769
self.exception_long = error_ctx.exception_long
770770
raise self.exception_obj
771771
if not mod:
772-
if not self.allow_all_imports and imp.name not in ALLOWED_IMPORTS:
772+
if (
773+
not self.config_entry.data.get(CONF_ALLOW_ALL_IMPORTS, False)
774+
and imp.name not in ALLOWED_IMPORTS
775+
):
773776
raise ModuleNotFoundError(f"import of {imp.name} not allowed")
774777
if imp.name not in sys.modules:
775778
mod = await Function.hass.async_add_executor_job(importlib.import_module, imp.name)
@@ -799,7 +802,10 @@ async def ast_importfrom(self, arg):
799802
self.exception_long = error_ctx.exception_long
800803
raise self.exception_obj
801804
if not mod:
802-
if not self.allow_all_imports and arg.module not in ALLOWED_IMPORTS:
805+
if (
806+
not self.config_entry.data.get(CONF_ALLOW_ALL_IMPORTS, False)
807+
and arg.module not in ALLOWED_IMPORTS
808+
):
803809
raise ModuleNotFoundError(f"import from {arg.module} not allowed")
804810
if arg.module not in sys.modules:
805811
mod = await Function.hass.async_add_executor_job(importlib.import_module, arg.module)

custom_components/pyscript/strings.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,23 @@
1414
"single_instance_allowed": "Already configured. Only a single configuration possible.",
1515
"updated_entry": "This entry has already been setup but the configuration has been updated."
1616
}
17+
},
18+
"options": {
19+
"step": {
20+
"init": {
21+
"title": "Update pyscript configuration",
22+
"data": {
23+
"allow_all_imports": "Allow All Imports?"
24+
}
25+
},
26+
"no_ui_configuration_allowed": {
27+
"title": "No UI configuration allowed",
28+
"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."
29+
},
30+
"no_update": {
31+
"title": "No update needed",
32+
"description": "There is nothing to update."
33+
}
34+
}
1735
}
18-
}
36+
}

custom_components/pyscript/translations/en.json

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,23 @@
1414
"single_instance_allowed": "Already configured. Only a single configuration possible.",
1515
"updated_entry": "This entry has already been setup but the configuration has been updated."
1616
}
17+
},
18+
"options": {
19+
"step": {
20+
"init": {
21+
"title": "Update pyscript configuration",
22+
"data": {
23+
"allow_all_imports": "Allow All Imports?"
24+
}
25+
},
26+
"no_ui_configuration_allowed": {
27+
"title": "No UI configuration allowed",
28+
"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."
29+
},
30+
"no_update": {
31+
"title": "No update needed",
32+
"description": "There is nothing to update."
33+
}
34+
}
1735
}
18-
}
36+
}

tests/test_config_flow.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,3 +174,75 @@ async def test_import_flow_update_import(hass):
174174
assert result["reason"] == "updated_entry"
175175

176176
assert hass.config_entries.async_entries(DOMAIN)[0].data == {"apps": {"test_app": {"param": 1}}}
177+
178+
179+
async def test_options_flow_import(hass):
180+
"""Test options flow aborts because configuration needs to be managed via configuration.yaml."""
181+
result = await hass.config_entries.flow.async_init(
182+
DOMAIN, context={"source": SOURCE_IMPORT}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True})
183+
)
184+
await hass.async_block_till_done()
185+
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
186+
entry = result["result"]
187+
188+
result = await hass.config_entries.options.async_init(entry.entry_id, data=None)
189+
190+
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
191+
assert result["step_id"] == "no_ui_configuration_allowed"
192+
193+
result = await hass.config_entries.options.async_configure(result["flow_id"], user_input=None)
194+
195+
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
196+
assert result["title"] == ""
197+
198+
199+
async def test_options_flow_user_change(hass):
200+
"""Test options flow updates config entry when options change."""
201+
result = await hass.config_entries.flow.async_init(
202+
DOMAIN, context={"source": SOURCE_USER}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True})
203+
)
204+
await hass.async_block_till_done()
205+
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
206+
entry = result["result"]
207+
208+
result = await hass.config_entries.options.async_init(entry.entry_id)
209+
210+
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
211+
assert result["step_id"] == "init"
212+
213+
result = await hass.config_entries.options.async_configure(
214+
result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: False}
215+
)
216+
await hass.async_block_till_done()
217+
218+
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
219+
assert result["title"] == ""
220+
221+
assert entry.data[CONF_ALLOW_ALL_IMPORTS] is False
222+
223+
224+
async def test_options_flow_user_no_change(hass):
225+
"""Test options flow aborts when options don't change."""
226+
result = await hass.config_entries.flow.async_init(
227+
DOMAIN, context={"source": SOURCE_USER}, data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True})
228+
)
229+
await hass.async_block_till_done()
230+
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
231+
entry = result["result"]
232+
233+
result = await hass.config_entries.options.async_init(entry.entry_id)
234+
235+
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
236+
assert result["step_id"] == "init"
237+
238+
result = await hass.config_entries.options.async_configure(
239+
result["flow_id"], user_input={CONF_ALLOW_ALL_IMPORTS: True}
240+
)
241+
242+
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
243+
assert result["step_id"] == "no_update"
244+
245+
result = await hass.config_entries.options.async_configure(result["flow_id"], user_input=None)
246+
247+
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
248+
assert result["title"] == ""

tests/test_unit_eval.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Unit tests for Python interpreter."""
22

3+
from custom_components.pyscript.const import CONF_ALLOW_ALL_IMPORTS, DOMAIN
34
from custom_components.pyscript.eval import AstEval
45
from custom_components.pyscript.function import Function
56
from custom_components.pyscript.global_ctx import GlobalContext, GlobalContextMgr
67
from custom_components.pyscript.state import State
8+
from pytest_homeassistant_custom_component.common import MockConfigEntry
79

810
evalTests = [
911
["1", 1],
@@ -882,6 +884,7 @@ async def run_one_test(test_data):
882884

883885
async def test_eval(hass):
884886
"""Test interpreter."""
887+
hass.data[DOMAIN] = MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: False})
885888
Function.init(hass)
886889
State.init(hass)
887890
State.register_functions()
@@ -1062,6 +1065,7 @@ async def run_one_test_exception(test_data):
10621065

10631066
async def test_eval_exceptions(hass):
10641067
"""Test interpreter exceptions."""
1068+
hass.data[DOMAIN] = MockConfigEntry(domain=DOMAIN, data={CONF_ALLOW_ALL_IMPORTS: False})
10651069
Function.init(hass)
10661070
State.init(hass)
10671071
State.register_functions()

0 commit comments

Comments
 (0)