@@ -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