Skip to content

Commit a7314cb

Browse files
committed
reload only reloads changed files; see #106
1 parent f1d2449 commit a7314cb

14 files changed

+684
-135
lines changed

custom_components/pyscript/__init__.py

Lines changed: 226 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -150,12 +150,6 @@ async def reload_scripts_handler(call):
150150

151151
global_ctx_only = call.data.get("global_ctx", None)
152152

153-
if global_ctx_only is not None and not GlobalContextMgr.get(global_ctx_only):
154-
_LOGGER.error("pyscript.reload: no global context '%s' to reload", global_ctx_only)
155-
return
156-
157-
await unload_scripts(global_ctx_only=global_ctx_only)
158-
159153
await install_requirements(hass, config_entry, pyscript_folder)
160154
await load_scripts(hass, config_entry.data, global_ctx_only=global_ctx_only)
161155

@@ -260,19 +254,47 @@ async def unload_scripts(global_ctx_only=None, unload_all=False):
260254
global_ctx.stop()
261255
ctx_delete[global_ctx_name] = global_ctx
262256
for global_ctx_name, global_ctx in ctx_delete.items():
263-
await GlobalContextMgr.delete(global_ctx_name)
257+
GlobalContextMgr.delete(global_ctx_name)
264258

265259

266260
@bind_hass
267-
async def load_scripts(hass, data, global_ctx_only=None):
261+
async def load_scripts(hass, config_data, global_ctx_only=None):
268262
"""Load all python scripts in FOLDER."""
269263

264+
class SourceFile:
265+
"""Class for information about a source file."""
266+
267+
def __init__(
268+
self,
269+
global_ctx_name=None,
270+
file_path=None,
271+
rel_path=None,
272+
rel_import_path=None,
273+
fq_mod_name=None,
274+
check_config=None,
275+
app_config=None,
276+
source=None,
277+
mtime=None,
278+
autoload=None,
279+
):
280+
self.global_ctx_name = global_ctx_name
281+
self.file_path = file_path
282+
self.rel_path = rel_path
283+
self.rel_import_path = rel_import_path
284+
self.fq_mod_name = fq_mod_name
285+
self.check_config = check_config
286+
self.app_config = app_config
287+
self.source = source
288+
self.mtime = mtime
289+
self.autoload = autoload
290+
self.force = False
291+
270292
pyscript_dir = hass.config.path(FOLDER)
271293

272-
def glob_files(load_paths, data):
273-
source_files = []
274-
apps_config = data.get("apps", None)
275-
for path, match, check_config in load_paths:
294+
def glob_read_files(load_paths, apps_config):
295+
"""Expand globs and read all the source files."""
296+
ctx2source = {}
297+
for path, match, check_config, autoload in load_paths:
276298
for this_path in sorted(glob.glob(os.path.join(pyscript_dir, path, match), recursive=True)):
277299
rel_import_path = None
278300
rel_path = this_path
@@ -281,12 +303,13 @@ def glob_files(load_paths, data):
281303
if rel_path.startswith("/"):
282304
rel_path = rel_path[1:]
283305
if rel_path[0] == "#" or rel_path.find("/#") >= 0:
306+
# skip "commented" files and directories
284307
continue
285-
rel_path = rel_path[0:-3]
286-
if rel_path.endswith("/__init__"):
287-
rel_path = rel_path[0 : -len("/__init__")]
288-
rel_import_path = rel_path
289-
mod_name = rel_path.replace("/", ".")
308+
mod_name = rel_path[0:-3]
309+
if mod_name.endswith("/__init__"):
310+
# mod_name = mod_name[0 : -len("/__init__")]
311+
rel_import_path = mod_name
312+
mod_name = mod_name.replace("/", ".")
290313
if path == "":
291314
global_ctx_name = f"file.{mod_name}"
292315
fq_mod_name = mod_name
@@ -295,30 +318,199 @@ def glob_files(load_paths, data):
295318
i = fq_mod_name.find(".")
296319
if i >= 0:
297320
fq_mod_name = fq_mod_name[i + 1 :]
321+
app_config = None
322+
323+
if global_ctx_name in ctx2source:
324+
# the globs result in apps/APP/__init__.py matching twice, so skip the 2nd time
325+
continue
326+
298327
if check_config:
299-
if not isinstance(apps_config, dict) or fq_mod_name not in apps_config:
300-
_LOGGER.debug("load_scripts: skipping %s because config not present", this_path)
328+
app_name = fq_mod_name
329+
i = fq_mod_name.find(".")
330+
if i >= 0:
331+
app_name = app_name[0:i]
332+
if not isinstance(apps_config, dict) or app_name not in apps_config:
333+
_LOGGER.debug(
334+
"load_scripts: skipping %s (app_name=%s) because config not present",
335+
this_path,
336+
app_name,
337+
)
301338
continue
302-
source_files.append([global_ctx_name, this_path, rel_import_path, fq_mod_name])
339+
app_config = apps_config[app_name]
340+
341+
try:
342+
with open(this_path) as file_desc:
343+
source = file_desc.read()
344+
mtime = os.path.getmtime(this_path)
345+
except Exception as exc:
346+
_LOGGER.error("load_scripts: skipping %s due to exception %s", this_path, exc)
347+
continue
303348

304-
return source_files
349+
ctx2source[global_ctx_name] = SourceFile(
350+
global_ctx_name=global_ctx_name,
351+
file_path=this_path,
352+
rel_path=rel_path,
353+
rel_import_path=rel_import_path,
354+
fq_mod_name=fq_mod_name,
355+
check_config=check_config,
356+
app_config=app_config,
357+
source=source,
358+
mtime=mtime,
359+
autoload=autoload,
360+
)
361+
362+
return ctx2source
305363

306364
load_paths = [
307-
["apps", "*.py", True],
308-
["apps", "*/__init__.py", True],
309-
["", "*.py", False],
310-
["scripts", "**/*.py", False],
365+
# directory, glob, check_config, autoload
366+
["", "*.py", False, True],
367+
["apps", "*.py", True, True],
368+
["apps", "*/__init__.py", True, True],
369+
["apps", "*/**/*.py", False, False],
370+
["modules", "*.py", False, False],
371+
["modules", "*/**/*.py", False, False],
372+
["scripts", "**/*.py", False, True],
311373
]
312374

313-
source_files = await hass.async_add_executor_job(glob_files, load_paths, data)
314-
for global_ctx_name, source_file, rel_import_path, fq_mod_name in source_files:
315-
if global_ctx_only is not None:
316-
if global_ctx_name != global_ctx_only and not global_ctx_name.startswith(global_ctx_only + "."):
317-
continue
375+
#
376+
# get current global contexts
377+
#
378+
ctx_all = {}
379+
for global_ctx_name, global_ctx in GlobalContextMgr.items():
380+
idx = global_ctx_name.find(".")
381+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps", "modules", "scripts"}:
382+
continue
383+
ctx_all[global_ctx_name] = global_ctx
384+
385+
#
386+
# get list and contents of all source files
387+
#
388+
apps_config = config_data.get("apps", None)
389+
ctx2files = await hass.async_add_executor_job(glob_read_files, load_paths, apps_config)
390+
391+
#
392+
# figure out what to reload based on global_ctx_only and what's changed
393+
#
394+
ctx_delete = set()
395+
if global_ctx_only is not None and global_ctx_only != "*":
396+
if global_ctx_only not in ctx_all and global_ctx_only not in ctx2files:
397+
_LOGGER.error("pyscript.reload: no global context '%s' to reload", global_ctx_only)
398+
return
399+
if global_ctx_only not in ctx2files:
400+
ctx_delete.add(global_ctx_only)
401+
else:
402+
ctx2files[global_ctx_only].force = True
403+
elif global_ctx_only == "*":
404+
ctx_delete = set(ctx_all.keys())
405+
for _, src_info in ctx2files.items():
406+
src_info.force = True
407+
else:
408+
# delete all global_ctxs that aren't present in current files
409+
for global_ctx_name, global_ctx in ctx_all.items():
410+
if global_ctx_name not in ctx2files:
411+
ctx_delete.add(global_ctx_name)
412+
# delete all global_ctxs that have changeed source or mtime
413+
for global_ctx_name, src_info in ctx2files.items():
414+
if global_ctx_name in ctx_all:
415+
ctx = ctx_all[global_ctx_name]
416+
if (
417+
src_info.source != ctx.get_source()
418+
or src_info.app_config != ctx.get_app_config()
419+
or src_info.mtime != ctx.get_mtime()
420+
):
421+
ctx_delete.add(global_ctx_name)
422+
src_info.force = True
423+
else:
424+
src_info.force = src_info.autoload
425+
426+
#
427+
# force reload if any files uses a module that is bring reloaded by
428+
# recursively following each import; first find which modules are
429+
# being reloaded
430+
#
431+
will_reload = set()
432+
for global_ctx_name, src_info in ctx2files.items():
433+
if global_ctx_name.startswith("modules.") and (global_ctx_name in ctx_delete or src_info.force):
434+
parts = global_ctx_name.split(".")
435+
root = f"{parts[0]}.{parts[1]}"
436+
will_reload.add(root)
437+
438+
if len(will_reload) > 0:
439+
440+
def import_recurse(ctx_name, visited, ctx2imports):
441+
if ctx_name in visited or ctx_name in ctx2imports:
442+
return ctx2imports.get(ctx_name, set())
443+
visited.add(ctx_name)
444+
ctx = GlobalContextMgr.get(ctx_name)
445+
if not ctx:
446+
return set()
447+
ctx2imports[ctx_name] = set()
448+
for imp_name in ctx.get_imports():
449+
ctx2imports[ctx_name].add(imp_name)
450+
ctx2imports[ctx_name].update(import_recurse(imp_name, visited, ctx2imports))
451+
return ctx2imports[ctx_name]
452+
453+
ctx2imports = {}
454+
for global_ctx_name, global_ctx in ctx_all.items():
455+
if global_ctx_name not in ctx2imports:
456+
visited = set()
457+
import_recurse(global_ctx_name, visited, ctx2imports)
458+
for mod_name in ctx2imports.get(global_ctx_name, set()):
459+
parts = mod_name.split(".")
460+
root = f"{parts[0]}.{parts[1]}"
461+
if root in will_reload:
462+
ctx_delete.add(global_ctx_name)
463+
if global_ctx_name in ctx2files:
464+
ctx2files[global_ctx_name].force = True
465+
466+
#
467+
# if any file in an app or module has changed, then reload just the top-level
468+
# __init__.py or module/app .py file, and delete everything else
469+
#
470+
done = set()
471+
for global_ctx_name, src_info in ctx2files.items():
472+
if not src_info.force:
473+
continue
474+
if not global_ctx_name.startswith("apps.") and not global_ctx_name.startswith("modules."):
475+
continue
476+
parts = global_ctx_name.split(".")
477+
root = f"{parts[0]}.{parts[1]}"
478+
if root in done:
479+
continue
480+
pkg_path = f"{parts[0]}/{parts[1]}/__init__.py"
481+
mod_path = f"{parts[0]}/{parts[1]}.py"
482+
for ctx_name, this_src_info in ctx2files.items():
483+
if ctx_name == root or ctx_name.startswith(f"{root}."):
484+
if this_src_info.rel_path in {pkg_path, mod_path}:
485+
this_src_info.force = True
486+
else:
487+
this_src_info.force = False
488+
ctx_delete.add(ctx_name)
489+
done.add(root)
490+
491+
#
492+
# delete contexts that are no longer needed or will be reloaded
493+
#
494+
for global_ctx_name in ctx_delete:
495+
if global_ctx_name in ctx_all:
496+
global_ctx = ctx_all[global_ctx_name]
497+
global_ctx.stop()
498+
_LOGGER.debug("reload: deleting global_ctx=%s", global_ctx_name)
499+
GlobalContextMgr.delete(global_ctx_name)
500+
501+
#
502+
# now load the requested files, and files that depend on loaded files
503+
#
504+
for global_ctx_name, src_info in sorted(ctx2files.items()):
505+
if not src_info.autoload or not src_info.force:
506+
continue
318507
global_ctx = GlobalContext(
319-
global_ctx_name,
320-
global_sym_table={"__name__": fq_mod_name},
508+
src_info.global_ctx_name,
509+
global_sym_table={"__name__": src_info.fq_mod_name},
321510
manager=GlobalContextMgr,
322-
rel_import_path=rel_import_path,
511+
rel_import_path=src_info.rel_import_path,
512+
app_config=src_info.app_config,
513+
source=src_info.source,
514+
mtime=src_info.mtime,
323515
)
324-
await GlobalContextMgr.load_file(source_file, global_ctx)
516+
await GlobalContextMgr.load_file(global_ctx, src_info.file_path, source=src_info.source, force=True)

0 commit comments

Comments
 (0)