Skip to content

Commit 0d4c5a6

Browse files
committed
UI reload now the same as reload service
1 parent 84f55e6 commit 0d4c5a6

File tree

11 files changed

+122
-113
lines changed

11 files changed

+122
-113
lines changed

custom_components/pyscript/__init__.py

Lines changed: 53 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,45 @@ async def restore_state(hass):
7373
hass.states.async_set(entity_id, last_state.state, last_state.attributes)
7474

7575

76+
async def update_yaml_config(hass, config_entry):
77+
"""Update the yaml config."""
78+
try:
79+
conf = await async_hass_config_yaml(hass)
80+
except HomeAssistantError as err:
81+
_LOGGER.error(err)
82+
return
83+
84+
config = PYSCRIPT_SCHEMA(conf.get(DOMAIN, {}))
85+
86+
#
87+
# If data in config doesn't match config entry, trigger a config import
88+
# so that the config entry can get updated
89+
#
90+
if config != config_entry.data:
91+
await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=config)
92+
93+
94+
def start_global_contexts():
95+
"""Start all the file and apps global contexts."""
96+
start_list = []
97+
for global_ctx_name, global_ctx in GlobalContextMgr.items():
98+
idx = global_ctx_name.find(".")
99+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
100+
continue
101+
global_ctx.set_auto_start(True)
102+
start_list.append(global_ctx)
103+
for global_ctx in start_list:
104+
global_ctx.start()
105+
106+
76107
async def async_setup_entry(hass, config_entry):
77108
"""Initialize the pyscript config entry."""
109+
if Function.hass:
110+
#
111+
# reload yaml if this isn't the first time (ie, on reload)
112+
#
113+
await update_yaml_config(hass, config_entry)
114+
78115
Function.init(hass)
79116
Event.init(hass)
80117
TrigTime.init(hass)
@@ -83,7 +120,6 @@ async def async_setup_entry(hass, config_entry):
83120
GlobalContextMgr.init()
84121

85122
pyscript_folder = hass.config.path(FOLDER)
86-
87123
if not await hass.async_add_executor_job(os.path.isdir, pyscript_folder):
88124
_LOGGER.debug("Folder %s not found in configuration folder, creating it", FOLDER)
89125
await hass.async_add_executor_job(os.makedirs, pyscript_folder)
@@ -100,36 +136,14 @@ async def reload_scripts_handler(call):
100136
"""Handle reload service calls."""
101137
_LOGGER.debug("reload: yaml, reloading scripts, and restarting")
102138

103-
try:
104-
conf = await async_hass_config_yaml(hass)
105-
except HomeAssistantError as err:
106-
_LOGGER.error(err)
107-
return
108-
109-
config = PYSCRIPT_SCHEMA(conf.get(DOMAIN, {}))
110-
111-
# If data in config doesn't match config entry, trigger a config import
112-
# so that the config entry can get updated
113-
if config != config_entry.data:
114-
await hass.config_entries.flow.async_init(DOMAIN, context={"source": SOURCE_IMPORT}, data=config)
115-
139+
await update_yaml_config(hass, config_entry)
116140
State.set_pyscript_config(config_entry.data)
117141

118142
await unload_scripts()
119143

120144
await load_scripts(hass, config_entry.data)
121145

122-
for global_ctx_name, global_ctx in GlobalContextMgr.items():
123-
idx = global_ctx_name.find(".")
124-
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
125-
continue
126-
global_ctx.set_auto_start(True)
127-
128-
for global_ctx_name, global_ctx in GlobalContextMgr.items():
129-
idx = global_ctx_name.find(".")
130-
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
131-
continue
132-
global_ctx.start()
146+
start_global_contexts()
133147

134148
hass.services.async_register(DOMAIN, SERVICE_RELOAD, reload_scripts_handler)
135149

@@ -176,32 +190,22 @@ async def state_changed(event):
176190
}
177191
await State.update(new_vars, func_args)
178192

179-
async def start_triggers(event):
180-
_LOGGER.debug("adding state changed listener and starting triggers")
193+
async def hass_started(event):
194+
_LOGGER.debug("adding state changed listener and starting global contexts")
181195
hass.data[DOMAIN][UNSUB_LISTENERS].append(hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed))
182-
for global_ctx_name, global_ctx in GlobalContextMgr.items():
183-
idx = global_ctx_name.find(".")
184-
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
185-
continue
186-
global_ctx.set_auto_start(True)
187-
for global_ctx_name, global_ctx in GlobalContextMgr.items():
188-
idx = global_ctx_name.find(".")
189-
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
190-
continue
191-
global_ctx.start()
196+
start_global_contexts()
192197

193-
async def stop_triggers(event):
194-
_LOGGER.debug("stopping triggers")
195-
for _, global_ctx in GlobalContextMgr.items():
196-
global_ctx.stop()
198+
async def hass_stop(event):
199+
_LOGGER.debug("stopping global contexts")
200+
await unload_scripts(unload_all=True)
197201
# tell reaper task to exit (after other tasks are cancelled)
198202
await Function.reaper_stop()
199203

200204
# Store callbacks to event listeners so we can unsubscribe on unload
201205
hass.data[DOMAIN][UNSUB_LISTENERS].append(
202-
hass.bus.async_listen(EVENT_HOMEASSISTANT_STARTED, start_triggers)
206+
hass.bus.async_listen(EVENT_HOMEASSISTANT_STARTED, hass_started)
203207
)
204-
hass.data[DOMAIN][UNSUB_LISTENERS].append(hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, stop_triggers))
208+
hass.data[DOMAIN][UNSUB_LISTENERS].append(hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, hass_stop))
205209

206210
return True
207211

@@ -211,9 +215,6 @@ async def async_unload_entry(hass, config_entry):
211215
# Unload scripts
212216
await unload_scripts()
213217

214-
# tell reaper task to exit (after other tasks are cancelled)
215-
await Function.reaper_stop()
216-
217218
# Unsubscribe from listeners
218219
for unsub_listener in hass.data[DOMAIN][UNSUB_LISTENERS]:
219220
unsub_listener()
@@ -222,13 +223,14 @@ async def async_unload_entry(hass, config_entry):
222223
return True
223224

224225

225-
async def unload_scripts():
226-
"""Unload all scripts from GlobalContextMgr."""
226+
async def unload_scripts(unload_all=False):
227+
"""Unload all scripts from GlobalContextMgr with given name prefixes."""
227228
ctx_delete = {}
228229
for global_ctx_name, global_ctx in GlobalContextMgr.items():
229-
idx = global_ctx_name.find(".")
230-
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps", "modules"}:
231-
continue
230+
if not unload_all:
231+
idx = global_ctx_name.find(".")
232+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps", "modules"}:
233+
continue
232234
global_ctx.stop()
233235
ctx_delete[global_ctx_name] = global_ctx
234236
for global_ctx_name, global_ctx in ctx_delete.items():

custom_components/pyscript/function.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,11 @@ class Function:
4141
#
4242
ast_functions = {}
4343

44+
#
45+
# task id of the task that cancel and waits for other tasks
46+
#
47+
task_cancel_repeaer = None
48+
4449
def __init__(self):
4550
"""Warn on Function instantiation."""
4651
_LOGGER.error("Function class is not meant to be instantiated")
@@ -89,14 +94,18 @@ async def task_cancel_reaper(reaper_q):
8994
except Exception:
9095
_LOGGER.error("task_cancel_reaper: got exception %s", traceback.format_exc(-1))
9196

92-
cls.task_reaper_q = asyncio.Queue(0)
93-
cls.task_cancel_repeaer = Function.create_task(task_cancel_reaper(cls.task_reaper_q))
97+
if not cls.task_cancel_repeaer:
98+
cls.task_reaper_q = asyncio.Queue(0)
99+
cls.task_cancel_repeaer = Function.create_task(task_cancel_reaper(cls.task_reaper_q))
94100

95101
@classmethod
96102
async def reaper_stop(cls):
97103
"""Tell the reaper task to exit by sending a special task None."""
98-
cls.task_cancel(None)
99-
await cls.task_cancel_repeaer
104+
if cls.task_cancel_repeaer:
105+
cls.task_cancel(None)
106+
await cls.task_cancel_repeaer
107+
cls.task_cancel_repeaer = None
108+
cls.task_reaper_q = None
100109

101110
@classmethod
102111
async def async_sleep(cls, duration):

custom_components/pyscript/state.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,15 +125,15 @@ async def set(cls, var_name, value, new_attributes=None, **kwargs):
125125

126126
@classmethod
127127
async def register_persist(cls, var_name):
128-
"""Persists a pyscript state variable using RestoreState."""
128+
"""Register pyscript state variable to be persisted with RestoreState."""
129129
if var_name.startswith("pyscript.") and var_name not in cls.persisted_vars:
130130
restore_data = await RestoreStateData.async_get_instance(cls.hass)
131131
restore_data.async_restore_entity_added(var_name)
132132
cls.persisted_vars.add(var_name)
133133

134134
@classmethod
135135
async def persist(cls, var_name, default_value=None, default_attributes=None):
136-
"""Ensures a pyscript domain state variable is persisted."""
136+
"""Persist a pyscript domain state variable, and update with optional defaults."""
137137
if var_name.count(".") != 1 or not var_name.startswith("pyscript."):
138138
raise NameError(f"invalid name {var_name} (should be 'pyscript.entity')")
139139

docs/reference.rst

Lines changed: 29 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ Configuration
66

77
Pyscript can be configured using the UI, or via yaml. To use the UI, go to the
88
Configuration -> Integrations page and selection "+" to add ``Pyscript Python scripting``.
9-
After that, you can change the settings anytime by selecting Options under Pyscript
10-
in the Configuration page.
9+
After that, you can change the settings anytime by selecting "Options" under Pyscript
10+
in the Configuration page. You will need to select "reload" under Pyscript, or call
11+
the ``pyscript.reload`` service for the new settings to take effect.
1112

1213
Alternatively, for yaml configuration, add ``pyscript:`` to ``<config>/configuration.yaml``.
1314
You can't mix these two methods - your initial choice determines how you should update
@@ -16,7 +17,8 @@ uninstall and reinstall pyscript.
1617

1718
Pyscript has two optional configuration parameters that allow any python package to be
1819
imported and exposes the ``hass`` variable as a global (both options default to ``false``).
19-
In `<config>/configuration.yaml``:
20+
Assuming you didn't use the UI to configure pyscript, these can be set
21+
in `<config>/configuration.yaml``:
2022

2123
.. code:: yaml
2224
@@ -44,6 +46,10 @@ For example, applications ``my_app1`` and ``my_app2`` would be configured as:
4446
my_app2:
4547
# any settings for my_app2 go here
4648
49+
Note that if you used the UI flow to configure pyscript, the ``allow_all_imports`` and
50+
`hass_is_global`` configuration settings will be ignored in the yaml file. In that case
51+
you should omit them from the yaml, and just use yaml for pycript app configuration.
52+
4753
As explained below, the use of ``apps`` with entries for each application by name below,
4854
is used to determine which application scripts are autoloaded. That's the only configuration
4955
structure that pyscript checks - any other parameters can be added and used as you like.
@@ -86,11 +92,12 @@ Even if you can’t directly call one function from another script file, HASS st
8692
global and services can be called from any script file.
8793

8894
Reloading the ``.py`` files is accomplished by calling the ``pyscript.reload`` service, which is the
89-
one built-in service (so you can’t create your own service with that name). All function
90-
definitions, services and triggers are re-created on ``reload``, except for any active Jupyter
91-
sessions. Any currently running functions (ie, functions that have been triggered and are actively
92-
executing Python code or waiting inside ``task.sleep()`` or ``task.wait_until()``) are not stopped
93-
by ``reload`` - they continue to run until they finish (return). You can terminate these running
95+
one built-in service (so you can’t create your own service with that name). You can also reload by
96+
selecting Configuration -> Integrations -> Pyscript -> reload in the UI. All function definitions,
97+
services and triggers are re-created on ``reload``, except for any active Jupyter sessions. Any
98+
currently running functions (ie, functions that have been triggered and are actively executing
99+
Python code or waiting inside ``task.sleep()`` or ``task.wait_until()``) are not stopped by
100+
``reload`` - they continue to run until they finish (return). You can terminate these running
94101
functions too on ``reload`` if you wish by calling ``task.unique()`` in the script file preamble
95102
(ie, outside any function definition), so it is executed on load and reload, which will terminate
96103
any running functions that have previously called ``task.unique()`` with the same argument.
@@ -99,6 +106,8 @@ any running functions that have previously called ``task.unique()`` with the sam
99106
State Variables
100107
---------------
101108

109+
These are typically called entities or ``entity_id`` in HASS.
110+
102111
State variables can be accessed in any Python code simply by name. State variables (also called
103112
``entity_id``) are of the form ``DOMAIN.name``, where ``DOMAIN`` is typically the name of the
104113
component that sets that variable. You can set a state variable by assigning to it.
@@ -567,12 +576,14 @@ variable has a numeric value, you might want to convert it to a numeric type (eg
567576
Persistent State
568577
^^^^^^^^^^^^^^^^
569578

570-
This method is provided to indicate that a particular entity_id should be persisted. This is only effective for entitys in the `pyscript` domain.
579+
This function specifies that a the state variable ``entity_id`` should be persisted (ie, its value
580+
and attributes are preserved across HASS restarts). This only applies to entities in the ``pyscript``
581+
domain (ie, name starts with ``pyscript.``).
571582

572583
``state.persist(entity_id, default_value=None, default_attributes=None)``
573-
Indicates that the entity named in `entity_id` should be persisted. Optionally, a default value and default attributes can be provided.
574-
575-
584+
Indicates that the entity ``entity_id`` should be persisted. Optionally, a default value and
585+
default attributes (a ``dict``) can be specified, which are applied to the entity if it doesn't
586+
exist or doesn't have any attributes respectively.
576587

577588
Service Calls
578589
^^^^^^^^^^^^^
@@ -1218,28 +1229,10 @@ is optional in pyscript):
12181229
Persistent State
12191230
^^^^^^^^^^^^^^^^
12201231

1221-
Pyscript has the ability to persist state in the `pyscript.` domain. This means that setting an entity like `pyscript.test` will cause it to be restored to its previous state when Home Assistant is restarted.
1222-
1223-
This can be done in any of the usual ways to set the state of an `entity_id`:
1224-
1225-
.. code:: python
1226-
1227-
set.state('pyscript.test', 'on')
1228-
1229-
pyscript.test = 'on'
1230-
1231-
Attributes can be included:
1232-
1233-
.. code:: python
1234-
1235-
set.state('pyscript.test', 'on', friendly_name="Test", device_class="motion")
1236-
1237-
pyscript.test = 'on'
1238-
pyscript.test.friendly_name = 'Test'
1239-
pyscript.test.device_class = 'motion'
1240-
1241-
In order to ensure that the state of a particular entity persists, you need to request persistence explicitly. This must be done in a code location that will be certain to run at startup. Generally, this means outside of trigger functions.
1242-
1232+
Pyscript has the ability to persist state variables in the ``pyscript.`` domain, meaning their
1233+
values and attributes are preserved across HASS restarts. To specify that the value of a particular
1234+
entity persists, you need to request persistence explicitly. This must be done in a code location
1235+
that will be certain to run at startup.
12431236

12441237
.. code:: python
12451238
@@ -1250,10 +1243,5 @@ In order to ensure that the state of a particular entity persists, you need to r
12501243
light.turn_on('light.overhead')
12511244
pyscript.last_light_on = "light.overhead"
12521245
1253-
With this in place, `state.persist()` will be called every time this script is parsed, ensuring this particular state will persist.
1254-
1255-
1256-
1257-
1258-
1259-
1246+
With this in place, ``state.persist()`` will be called every time this script is parsed, ensuring the
1247+
``pyscript.last_light_on`` state variable state will persist between HASS restarts.

tests/test_apps_modules.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,16 @@ def glob_side_effect(path, recursive=None):
120120
print(f"glob_side_effect: path={path}, path_re={path_re}, result={result}")
121121
return result
122122

123+
conf = {"apps": {"world": {}}}
123124
with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch(
124125
"custom_components.pyscript.glob.iglob"
125126
) as mock_glob, patch("builtins.open", mock_open), patch(
126-
"homeassistant.config.load_yaml_config_file", return_value={}
127+
"homeassistant.config.load_yaml_config_file", return_value={"pyscript": conf}
127128
), patch(
128129
"os.path.isfile"
129130
) as mock_isfile:
130131
mock_isfile.side_effect = isfile_side_effect
131132
mock_glob.side_effect = glob_side_effect
132-
conf = {"apps": {"world": {}}}
133133
assert await async_setup_component(hass, "pyscript", {DOMAIN: conf})
134134

135135
notify_q = asyncio.Queue(0)

tests/test_config_flow.py

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
@pytest.fixture(name="pyscript_bypass_setup")
1616
def pyscript_bypass_setup_fixture():
1717
"""Mock component setup."""
18+
logging.getLogger("pytest_homeassistant_custom_component.common").setLevel(logging.WARNING)
1819
with patch("custom_components.pyscript.async_setup_entry", return_value=True):
1920
yield
2021

@@ -271,15 +272,16 @@ async def test_options_flow_user_no_change(hass, pyscript_bypass_setup):
271272

272273
async def test_config_entry_reload(hass):
273274
"""Test that config entry reload does not duplicate listeners."""
274-
result = await hass.config_entries.flow.async_init(
275-
DOMAIN,
276-
context={"source": SOURCE_USER},
277-
data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}),
278-
)
279-
await hass.async_block_till_done()
280-
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
281-
entry = result["result"]
282-
listeners = hass.bus.async_listeners()
283-
await hass.config_entries.async_reload(entry.entry_id)
284-
await hass.async_block_till_done()
285-
assert listeners == hass.bus.async_listeners()
275+
with patch("homeassistant.config.load_yaml_config_file", return_value={}):
276+
result = await hass.config_entries.flow.async_init(
277+
DOMAIN,
278+
context={"source": SOURCE_USER},
279+
data=PYSCRIPT_SCHEMA({CONF_ALLOW_ALL_IMPORTS: True, CONF_HASS_IS_GLOBAL: True}),
280+
)
281+
await hass.async_block_till_done()
282+
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
283+
entry = result["result"]
284+
listeners = hass.bus.async_listeners()
285+
await hass.config_entries.async_reload(entry.entry_id)
286+
await hass.async_block_till_done()
287+
assert listeners == hass.bus.async_listeners()

0 commit comments

Comments
 (0)