@@ -150,12 +150,6 @@ async def reload_scripts_handler(call):
150
150
151
151
global_ctx_only = call .data .get ("global_ctx" , None )
152
152
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
-
159
153
await install_requirements (hass , config_entry , pyscript_folder )
160
154
await load_scripts (hass , config_entry .data , global_ctx_only = global_ctx_only )
161
155
@@ -260,19 +254,47 @@ async def unload_scripts(global_ctx_only=None, unload_all=False):
260
254
global_ctx .stop ()
261
255
ctx_delete [global_ctx_name ] = global_ctx
262
256
for global_ctx_name , global_ctx in ctx_delete .items ():
263
- await GlobalContextMgr .delete (global_ctx_name )
257
+ GlobalContextMgr .delete (global_ctx_name )
264
258
265
259
266
260
@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 ):
268
262
"""Load all python scripts in FOLDER."""
269
263
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
+
270
292
pyscript_dir = hass .config .path (FOLDER )
271
293
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 :
276
298
for this_path in sorted (glob .glob (os .path .join (pyscript_dir , path , match ), recursive = True )):
277
299
rel_import_path = None
278
300
rel_path = this_path
@@ -281,12 +303,13 @@ def glob_files(load_paths, data):
281
303
if rel_path .startswith ("/" ):
282
304
rel_path = rel_path [1 :]
283
305
if rel_path [0 ] == "#" or rel_path .find ("/#" ) >= 0 :
306
+ # skip "commented" files and directories
284
307
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 ("/" , "." )
290
313
if path == "" :
291
314
global_ctx_name = f"file.{ mod_name } "
292
315
fq_mod_name = mod_name
@@ -295,30 +318,199 @@ def glob_files(load_paths, data):
295
318
i = fq_mod_name .find ("." )
296
319
if i >= 0 :
297
320
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
+
298
327
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
+ )
301
338
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
303
348
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
305
363
306
364
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 ],
311
373
]
312
374
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
318
507
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 },
321
510
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 ,
323
515
)
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