Skip to content

Commit 9ba7813

Browse files
committed
autoload changed scripts using watchdog; see #74
1 parent 8702d4e commit 9ba7813

File tree

12 files changed

+128
-4
lines changed

12 files changed

+128
-4
lines changed

custom_components/pyscript/__init__.py

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
"""Component to allow running Python scripts."""
22

3+
import asyncio
34
import glob
45
import json
56
import logging
67
import os
8+
import time
9+
import traceback
710

811
import voluptuous as vol
12+
from watchdog.events import DirModifiedEvent, FileSystemEventHandler
13+
import watchdog.observers
914

1015
from homeassistant.config import async_hass_config_yaml
1116
from homeassistant.config_entries import SOURCE_IMPORT
@@ -29,6 +34,8 @@
2934
LOGGER_PATH,
3035
SERVICE_JUPYTER_KERNEL_START,
3136
UNSUB_LISTENERS,
37+
WATCHDOG_OBSERVER,
38+
WATCHDOG_TASK,
3239
)
3340
from .eval import AstEval
3441
from .event import Event
@@ -109,6 +116,87 @@ def start_global_contexts(global_ctx_only=None):
109116
global_ctx.start()
110117

111118

119+
async def watchdog_start(hass, pyscript_folder, reload_scripts_handler):
120+
"""Start watchdog thread to look for changed files in pyscript_folder."""
121+
if WATCHDOG_OBSERVER in hass.data[DOMAIN]:
122+
return
123+
124+
class WatchDogHandler(FileSystemEventHandler):
125+
"""Class for handling watchdog events."""
126+
127+
def __init__(self, watchdog_q):
128+
self.watchdog_q = watchdog_q
129+
130+
def process(self, event):
131+
"""Send watchdog events to main loop task."""
132+
_LOGGER.debug("watchdog process(%s)", event)
133+
hass.loop.call_soon_threadsafe(self.watchdog_q.put_nowait, event)
134+
135+
def on_modified(self, event):
136+
"""File modified."""
137+
self.process(event)
138+
139+
def on_moved(self, event):
140+
"""File moved."""
141+
self.process(event)
142+
143+
def on_created(self, event):
144+
"""File created."""
145+
self.process(event)
146+
147+
def on_deleted(self, event):
148+
"""File deleted."""
149+
self.process(event)
150+
151+
async def task_watchdog(watchdog_q):
152+
def check_event(event, do_reload):
153+
"""Check if event should trigger a reload."""
154+
if event.is_directory:
155+
# don't reload if it's just a directory modified
156+
if isinstance(event, DirModifiedEvent):
157+
return do_reload
158+
return True
159+
# only reload if it's a script or requirements.txt file
160+
if event.src_path.endswith(".py") or event.src_path.endswith("/requirements.txt"):
161+
return True
162+
return do_reload
163+
164+
while True:
165+
try:
166+
#
167+
# since some file/dir changes create multiple events, we consume all
168+
# events in a small window; first # wait indefinitely for next event
169+
#
170+
do_reload = check_event(await watchdog_q.get(), False)
171+
#
172+
# now consume all additional events with 50ms timeout or 500ms elapsed
173+
#
174+
t_start = time.monotonic()
175+
while time.monotonic() - t_start < 0.5:
176+
try:
177+
do_reload = check_event(
178+
await asyncio.wait_for(watchdog_q.get(), timeout=0.05), do_reload
179+
)
180+
except asyncio.TimeoutError:
181+
break
182+
if do_reload:
183+
await reload_scripts_handler(None)
184+
185+
except asyncio.CancelledError:
186+
raise
187+
except Exception:
188+
_LOGGER.error("task_watchdog: got exception %s", traceback.format_exc(-1))
189+
190+
watchdog_q = asyncio.Queue(0)
191+
observer = watchdog.observers.Observer()
192+
if observer is not None:
193+
# don't run watchdog when we are testing (it patches to None)
194+
hass.data[DOMAIN][WATCHDOG_OBSERVER] = observer
195+
hass.data[DOMAIN][WATCHDOG_TASK] = Function.create_task(task_watchdog(watchdog_q))
196+
197+
observer.schedule(WatchDogHandler(watchdog_q), pyscript_folder, recursive=True)
198+
199+
112200
async def async_setup_entry(hass, config_entry):
113201
"""Initialize the pyscript config entry."""
114202
if Function.hass:
@@ -148,7 +236,7 @@ async def reload_scripts_handler(call):
148236

149237
await State.get_service_params()
150238

151-
global_ctx_only = call.data.get("global_ctx", None)
239+
global_ctx_only = call.data.get("global_ctx", None) if call else None
152240

153241
await install_requirements(hass, config_entry, pyscript_folder)
154242
await load_scripts(hass, config_entry.data, global_ctx_only=global_ctx_only)
@@ -210,8 +298,19 @@ async def hass_started(event):
210298
await State.get_service_params()
211299
hass.data[DOMAIN][UNSUB_LISTENERS].append(hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed))
212300
start_global_contexts()
301+
if WATCHDOG_OBSERVER in hass.data[DOMAIN]:
302+
observer = hass.data[DOMAIN][WATCHDOG_OBSERVER]
303+
observer.start()
213304

214305
async def hass_stop(event):
306+
if WATCHDOG_OBSERVER in hass.data[DOMAIN]:
307+
observer = hass.data[DOMAIN][WATCHDOG_OBSERVER]
308+
observer.stop()
309+
observer.join()
310+
del hass.data[DOMAIN][WATCHDOG_OBSERVER]
311+
Function.reaper_cancel(hass.data[DOMAIN][WATCHDOG_TASK])
312+
del hass.data[DOMAIN][WATCHDOG_TASK]
313+
215314
_LOGGER.debug("stopping global contexts")
216315
await unload_scripts(unload_all=True)
217316
# sync with waiter, and then tell waiter and reaper tasks to exit
@@ -225,6 +324,8 @@ async def hass_stop(event):
225324
)
226325
hass.data[DOMAIN][UNSUB_LISTENERS].append(hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, hass_stop))
227326

327+
await watchdog_start(hass, pyscript_folder, reload_scripts_handler)
328+
228329
return True
229330

230331

custom_components/pyscript/const.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
REQUIREMENTS_FILE = "requirements.txt"
2525
REQUIREMENTS_PATHS = ("", "apps/*", "modules/*")
2626

27+
WATCHDOG_OBSERVER = "watch_dog_observer"
28+
WATCHDOG_TASK = "watch_dog_task"
29+
2730
ALLOWED_IMPORTS = {
2831
"black",
2932
"cmath",

custom_components/pyscript/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"config_flow": true,
55
"documentation": "https://github.com/custom-components/pyscript",
66
"issue_tracker": "https://github.com/custom-components/pyscript/issues",
7-
"requirements": ["croniter==0.3.34"],
7+
"requirements": ["croniter==0.3.34", "watchdog==0.8.3"],
88
"ssdp": [],
99
"zeroconf": [],
1010
"homekit": {},

tests/test_apps_modules.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,8 @@ def glob_side_effect(path, recursive=None):
164164
"custom_components.pyscript.open", mock_open
165165
), patch(
166166
"homeassistant.config.load_yaml_config_file", return_value={"pyscript": conf}
167+
), patch(
168+
"custom_components.pyscript.watchdog_start", return_value=None
167169
), patch(
168170
"custom_components.pyscript.os.path.getmtime", return_value=1000
169171
), patch(

tests/test_config_flow.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,9 @@ async def test_options_flow_user_no_change(hass, pyscript_bypass_setup):
272272

273273
async def test_config_entry_reload(hass):
274274
"""Test that config entry reload does not duplicate listeners."""
275-
with patch("homeassistant.config.load_yaml_config_file", return_value={}):
275+
with patch("homeassistant.config.load_yaml_config_file", return_value={}), patch(
276+
"custom_components.pyscript.watchdog_start", return_value=None
277+
):
276278
result = await hass.config_entries.flow.async_init(
277279
DOMAIN,
278280
context={"source": SOURCE_USER},

tests/test_decorator_errors.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ async def setup_script(hass, notify_q, now, source):
2525
"homeassistant.config.load_yaml_config_file", return_value={}
2626
), patch(
2727
"custom_components.pyscript.open", mock_open(read_data=source)
28+
), patch(
29+
"custom_components.pyscript.watchdog_start", return_value=None
2830
), patch(
2931
"custom_components.pyscript.os.path.getmtime", return_value=1000
3032
), patch(

tests/test_function.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,8 @@ def glob_side_effect(path, recursive=None):
135135
"homeassistant.config.load_yaml_config_file", return_value=config
136136
), patch(
137137
"custom_components.pyscript.install_requirements", return_value=None,
138+
), patch(
139+
"custom_components.pyscript.watchdog_start", return_value=None
138140
), patch(
139141
"custom_components.pyscript.os.path.getmtime", return_value=1000
140142
), patch(

tests/test_init.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ async def setup_script(hass, notify_q, now, source):
3434
"custom_components.pyscript.open", mock_open(read_data=source)
3535
), patch(
3636
"homeassistant.config.load_yaml_config_file", return_value={}
37+
), patch(
38+
"custom_components.pyscript.watchdog_start", return_value=None
3739
), patch(
3840
"custom_components.pyscript.os.path.getmtime", return_value=1000
3941
), patch(
@@ -70,7 +72,9 @@ async def test_setup_makedirs_on_no_dir(hass, caplog):
7072
"""Test setup calls os.makedirs when no dir found."""
7173
with patch("custom_components.pyscript.os.path.isdir", return_value=False), patch(
7274
"custom_components.pyscript.os.makedirs"
73-
) as makedirs_call, patch("homeassistant.config.load_yaml_config_file", return_value={}):
75+
), patch("custom_components.pyscript.watchdog_start", return_value=None) as makedirs_call, patch(
76+
"homeassistant.config.load_yaml_config_file", return_value={}
77+
):
7478
res = await async_setup_component(hass, "pyscript", {DOMAIN: {}})
7579

7680
assert res

tests/test_jupyter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,8 @@ def glob_side_effect(path, recursive=None):
139139
"homeassistant.config.load_yaml_config_file", return_value={}
140140
), patch(
141141
"custom_components.pyscript.install_requirements", return_value=None,
142+
), patch(
143+
"custom_components.pyscript.watchdog_start", return_value=None
142144
), patch(
143145
"custom_components.pyscript.os.path.getmtime", return_value=1000
144146
), patch(

tests/test_reload.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ def glob_side_effect(path, recursive=None):
106106
"homeassistant.util.yaml.loader.open", mock_open
107107
), patch(
108108
"homeassistant.config.load_yaml_config_file", return_value={"pyscript": conf}
109+
), patch(
110+
"custom_components.pyscript.watchdog_start", return_value=None
109111
), patch(
110112
"custom_components.pyscript.os.path.getmtime", return_value=1000
111113
), patch(

tests/test_tasks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,8 @@ def glob_side_effect(path, recursive=None):
118118
"homeassistant.config.load_yaml_config_file", return_value={"pyscript": conf}
119119
), patch(
120120
"custom_components.pyscript.os.path.getmtime", return_value=1000
121+
), patch(
122+
"custom_components.pyscript.watchdog_start", return_value=None
121123
), patch(
122124
"custom_components.pyscript.global_ctx.os.path.getmtime", return_value=1000
123125
), patch(

tests/test_unique.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ async def setup_script(hass, notify_q, now, source):
2525
"custom_components.pyscript.trigger.dt_now", return_value=now
2626
), patch(
2727
"homeassistant.config.load_yaml_config_file", return_value={}
28+
), patch(
29+
"custom_components.pyscript.watchdog_start", return_value=None
2830
), patch(
2931
"custom_components.pyscript.os.path.getmtime", return_value=1000
3032
), patch(

0 commit comments

Comments
 (0)