Skip to content

Commit 7f49c11

Browse files
committed
revamped autoloading and importing as discussed in #26 and fixes #28
1 parent 9590dc9 commit 7f49c11

File tree

6 files changed

+353
-135
lines changed

6 files changed

+353
-135
lines changed

custom_components/pyscript/__init__.py

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def check_isdir(path):
6464

6565
State.set_pyscript_config(config.get(DOMAIN, {}))
6666

67-
await load_scripts(hass)
67+
await load_scripts(hass, config)
6868

6969
async def reload_scripts_handler(call):
7070
"""Handle reload service calls."""
@@ -80,7 +80,6 @@ async def reload_scripts_handler(call):
8080

8181
config = await async_process_component_config(hass, conf, integration)
8282

83-
# GlobalContext.global_sym_table_add("pyscript.config", config.get(DOMAIN, {}))
8483
State.set_pyscript_config(config.get(DOMAIN, {}))
8584

8685
ctx_delete = {}
@@ -89,18 +88,22 @@ async def reload_scripts_handler(call):
8988
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps", "modules"}:
9089
continue
9190
global_ctx.stop()
92-
global_ctx.set_auto_start(False)
9391
ctx_delete[global_ctx_name] = global_ctx
9492
for global_ctx_name, global_ctx in ctx_delete.items():
9593
await GlobalContextMgr.delete(global_ctx_name)
9694

97-
await load_scripts(hass)
95+
await load_scripts(hass, config)
9896

9997
for global_ctx_name, global_ctx in GlobalContextMgr.items():
10098
idx = global_ctx_name.find(".")
10199
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
102100
continue
103101
global_ctx.set_auto_start(True)
102+
103+
for global_ctx_name, global_ctx in GlobalContextMgr.items():
104+
idx = global_ctx_name.find(".")
105+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
106+
continue
104107
global_ctx.start()
105108

106109
hass.services.async_register(DOMAIN, SERVICE_RELOAD, reload_scripts_handler)
@@ -150,10 +153,15 @@ async def start_triggers(event):
150153
_LOGGER.debug("adding state changed listener and starting triggers")
151154
hass.bus.async_listen(EVENT_STATE_CHANGED, state_changed)
152155
for global_ctx_name, global_ctx in GlobalContextMgr.items():
153-
if not global_ctx_name.startswith("file."):
156+
idx = global_ctx_name.find(".")
157+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
154158
continue
155-
global_ctx.start()
156159
global_ctx.set_auto_start(True)
160+
for global_ctx_name, global_ctx in GlobalContextMgr.items():
161+
idx = global_ctx_name.find(".")
162+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
163+
continue
164+
global_ctx.start()
157165

158166
async def stop_triggers(event):
159167
_LOGGER.debug("stopping triggers")
@@ -169,25 +177,46 @@ async def stop_triggers(event):
169177

170178

171179
@bind_hass
172-
async def load_scripts(hass):
180+
async def load_scripts(hass, config):
173181
"""Load all python scripts in FOLDER."""
174182

175-
load_paths = [hass.config.path(FOLDER) + "/apps", hass.config.path(FOLDER)]
183+
pyscript_dir = hass.config.path(FOLDER)
176184

177-
_LOGGER.debug("load_scripts: load_paths = %s", load_paths)
178-
179-
def glob_files(load_paths, match):
185+
def glob_files(load_paths, config):
180186
source_files = []
181-
for path in load_paths:
182-
source_files += sorted(glob.glob(os.path.join(path, match)))
187+
apps_config = config.get(DOMAIN, {}).get("apps", None)
188+
for path, match, check_config in load_paths:
189+
for this_path in sorted(glob.glob(os.path.join(pyscript_dir, path, match))):
190+
rel_import_path = None
191+
elts = this_path.split("/")
192+
if match.find("/") < 0:
193+
# last entry without the .py
194+
mod_name = elts[-1][0:-3]
195+
else:
196+
# 2nd last entry
197+
mod_name = elts[-2]
198+
rel_import_path = f"{path}/mod_name"
199+
if path == "":
200+
global_ctx_name = f"file.{mod_name}"
201+
else:
202+
global_ctx_name = f"{path}.{mod_name}"
203+
if check_config:
204+
_LOGGER.debug("load_scripts: checking %s in %s", mod_name, apps_config)
205+
if not isinstance(apps_config, dict) or mod_name not in apps_config:
206+
_LOGGER.debug("load_scripts: skipping %s because config not present", this_path)
207+
continue
208+
source_files.append([global_ctx_name, this_path, rel_import_path])
183209
return source_files
184210

185-
source_files = await hass.async_add_executor_job(glob_files, load_paths, "*.py")
186-
187-
for source_file in sorted(source_files):
188-
name = os.path.splitext(os.path.basename(source_file))[0]
189-
190-
global_ctx = GlobalContext(f"file.{name}", global_sym_table={}, manager=GlobalContextMgr)
191-
global_ctx.set_auto_start(False)
211+
load_paths = [
212+
["apps", "*.py", True],
213+
["apps", "*/__init__.py", True],
214+
["", "*.py", False],
215+
]
192216

217+
source_files = await hass.async_add_executor_job(glob_files, load_paths, config)
218+
for global_ctx_name, source_file, rel_import_path in source_files:
219+
global_ctx = GlobalContext(
220+
global_ctx_name, global_sym_table={}, manager=GlobalContextMgr, rel_import_path=rel_import_path
221+
)
193222
await GlobalContextMgr.load_file(source_file, global_ctx)

custom_components/pyscript/eval.py

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -479,8 +479,8 @@ def trigger_stop(self):
479479
async def eval_decorators(self, ast_ctx):
480480
"""Evaluate the function decorators arguments."""
481481
self.decorators = []
482-
ast_ctx.code_str = self.code_str
483-
ast_ctx.code_list = self.code_list
482+
code_str, code_list = ast_ctx.code_str, ast_ctx.code_list
483+
ast_ctx.code_str, ast_ctx.code_list = self.code_str, self.code_list
484484
for dec in self.func_def.decorator_list:
485485
if isinstance(dec, ast.Call) and isinstance(dec.func, ast.Name):
486486
args = []
@@ -496,6 +496,7 @@ async def eval_decorators(self, ast_ctx):
496496
self.decorators.append([dec.id, None, None])
497497
else:
498498
_LOGGER.error("function %s has unexpected decorator type %s", self.name, dec)
499+
ast_ctx.code_str, ast_ctx.code_list = code_str, code_list
499500

500501
async def resolve_nonlocals(self, ast_ctx):
501502
"""Tag local variables and resolve nonlocals."""
@@ -624,8 +625,8 @@ async def call(self, ast_ctx, args=None, kwargs=None):
624625
sym_table[name] = EvalLocalVar(name)
625626
ast_ctx.sym_table_stack.append(ast_ctx.sym_table)
626627
ast_ctx.sym_table = sym_table
627-
ast_ctx.code_str = self.code_str
628-
ast_ctx.code_list = self.code_list
628+
code_str, code_list = ast_ctx.code_str, ast_ctx.code_list
629+
ast_ctx.code_str, ast_ctx.code_list = self.code_str, self.code_list
629630
self.exception = None
630631
self.exception_obj = None
631632
self.exception_long = None
@@ -642,6 +643,7 @@ async def call(self, ast_ctx, args=None, kwargs=None):
642643
break
643644
ast_ctx.sym_table = ast_ctx.sym_table_stack.pop()
644645
ast_ctx.curr_func = prev_func
646+
ast_ctx.code_str, ast_ctx.code_list = code_str, code_list
645647
return val
646648

647649

@@ -732,7 +734,12 @@ async def ast_module(self, arg):
732734
async def ast_import(self, arg):
733735
"""Execute import."""
734736
for imp in arg.names:
735-
mod = await self.global_ctx.module_import(imp.name)
737+
mod, error_ctx = await self.global_ctx.module_import(imp.name, 0)
738+
if error_ctx:
739+
self.exception_obj = error_ctx.exception_obj
740+
self.exception = error_ctx.exception
741+
self.exception_long = error_ctx.exception_long
742+
raise self.exception_obj
736743
if not mod:
737744
if not self.allow_all_imports and imp.name not in ALLOWED_IMPORTS:
738745
raise ModuleNotFoundError(f"import of {imp.name} not allowed")
@@ -744,7 +751,26 @@ async def ast_import(self, arg):
744751

745752
async def ast_importfrom(self, arg):
746753
"""Execute from X import Y."""
747-
mod = await self.global_ctx.module_import(arg.module)
754+
if arg.module is None:
755+
# handle: "from . import xyz"
756+
for imp in arg.names:
757+
mod, error_ctx = await self.global_ctx.module_import(imp.name, arg.level)
758+
if error_ctx:
759+
self.exception_obj = error_ctx.exception_obj
760+
self.exception = error_ctx.exception
761+
self.exception_long = error_ctx.exception_long
762+
raise self.exception_obj
763+
if not mod:
764+
raise ModuleNotFoundError(f"module '{imp.name}' not found")
765+
self.sym_table[imp.name if imp.asname is None else imp.asname] = mod
766+
return
767+
else:
768+
mod, error_ctx = await self.global_ctx.module_import(arg.module, arg.level)
769+
if error_ctx:
770+
self.exception_obj = error_ctx.exception_obj
771+
self.exception = error_ctx.exception
772+
self.exception_long = error_ctx.exception_long
773+
raise self.exception_obj
748774
if not mod:
749775
if not self.allow_all_imports and arg.module not in ALLOWED_IMPORTS:
750776
raise ModuleNotFoundError(f"import from {arg.module} not allowed")

custom_components/pyscript/global_ctx.py

Lines changed: 100 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
class GlobalContext:
1616
"""Define class for global variables and trigger context."""
1717

18-
def __init__(self, name, global_sym_table=None, manager=None):
18+
def __init__(self, name, global_sym_table=None, manager=None, rel_import_path=None):
1919
"""Initialize GlobalContext."""
2020
self.name = name
2121
self.global_sym_table = global_sym_table if global_sym_table else {}
@@ -25,6 +25,7 @@ def __init__(self, name, global_sym_table=None, manager=None):
2525
self.manager = manager
2626
self.auto_start = False
2727
self.module = None
28+
self.rel_import_path = rel_import_path
2829

2930
def trigger_register(self, func):
3031
"""Register a trigger function; return True if start now."""
@@ -50,11 +51,12 @@ def start(self):
5051
self.triggers_delay_start = set()
5152

5253
def stop(self):
53-
"""Stop all triggers."""
54+
"""Stop all triggers and auto_start."""
5455
for func in self.triggers:
5556
func.trigger_stop()
5657
self.triggers = set()
5758
self.triggers_delay_start = set()
59+
self.set_auto_start(False)
5860

5961
def get_name(self):
6062
"""Return the global context name."""
@@ -68,27 +70,97 @@ def get_trig_info(self, name, trig_args):
6870
"""Return a new trigger info instance with the given args."""
6971
return TrigInfo(name, trig_args, self)
7072

71-
async def module_import(self, module_name):
72-
"""Import a module from the pyscript/modules folder."""
73-
mod_ctx_name = f"module.{module_name}"
74-
mod_ctx = self.manager.get(mod_ctx_name)
75-
if not mod_ctx:
76-
77-
def check_isfile(filepath):
78-
return os.path.isfile(filepath)
79-
80-
filepath = Function.hass.config.path(FOLDER) + f"/modules/{module_name}.py"
81-
if await Function.hass.async_add_executor_job(check_isfile, filepath):
82-
mod = ModuleType(module_name)
83-
global_ctx = GlobalContext(mod_ctx_name, global_sym_table=mod.__dict__, manager=self.manager)
84-
global_ctx.set_auto_start(True)
85-
await self.manager.load_file(filepath, global_ctx)
86-
global_ctx.module = mod
87-
mod_ctx = self.manager.get(mod_ctx_name)
88-
89-
if mod_ctx and mod_ctx.module:
90-
return mod_ctx.module
91-
return None
73+
async def module_import(self, module_name, import_level):
74+
"""Import a pyscript module from the pyscript/modules or apps folder."""
75+
76+
pyscript_dir = Function.hass.config.path(FOLDER)
77+
module_path = module_name.replace(".", "/")
78+
filepaths = []
79+
80+
def find_first_file(filepaths):
81+
for ctx_name, path, rel_path in filepaths:
82+
abs_path = os.path.join(pyscript_dir, path)
83+
if os.path.isfile(abs_path):
84+
return [ctx_name, abs_path, rel_path]
85+
return None
86+
87+
#
88+
# first build a list of potential import files
89+
#
90+
if import_level > 0:
91+
if self.rel_import_path is None:
92+
raise ImportError("attempted relative import with no known parent package")
93+
path = self.rel_import_path
94+
ctx_name = self.name
95+
for _ in range(import_level - 1):
96+
path = os.path.basename(path)
97+
idx = ctx_name.rfind(".")
98+
if path.find("/") < 0 or idx < 0:
99+
raise ImportError("attempted relative import above parent package")
100+
ctx_name = ctx_name[0:idx]
101+
idx = ctx_name.rfind(".")
102+
if idx >= 0:
103+
ctx_name = f"{ctx_name[0:idx]}.{module_name}"
104+
filepaths.append([ctx_name, f"{path}/{module_path}.py", path])
105+
path += f"/{module_path}"
106+
filepaths.append([f"{ctx_name}.__init__", f"{path}/__init__.py", path])
107+
module_name = ctx_name[ctx_name.find(".") + 1 :]
108+
109+
else:
110+
if self.rel_import_path is not None and self.rel_import_path.startswith("apps/"):
111+
ctx_name = f"apps.{module_name}"
112+
filepaths.append([ctx_name, f"apps/{module_path}.py", f"apps/{module_path}"])
113+
filepaths.append(
114+
[f"{ctx_name}.__init__", f"apps/{module_path}/__init__.py", f"apps/{module_path}"]
115+
)
116+
117+
ctx_name = f"modules.{module_name}"
118+
filepaths.append([ctx_name, f"modules/{module_path}.py", None])
119+
filepaths.append(
120+
[f"{ctx_name}.__init__", f"modules/{module_path}/__init__.py", f"modules/{module_path}"]
121+
)
122+
123+
#
124+
# now see if we have loaded it already
125+
#
126+
for ctx_name, _, _ in filepaths:
127+
mod_ctx = self.manager.get(ctx_name)
128+
if mod_ctx and mod_ctx.module:
129+
_LOGGER.debug(
130+
"module_import: returning existing module %s, ctx = %s, mod = %s",
131+
module_name,
132+
ctx_name,
133+
mod_ctx.module,
134+
)
135+
return [mod_ctx.module, None]
136+
137+
#
138+
# not loaded already, so try to find and import it
139+
#
140+
_LOGGER.debug("module_import: for module %s, searching %s", module_name, filepaths)
141+
file_info = await Function.hass.async_add_executor_job(find_first_file, filepaths)
142+
if not file_info:
143+
return [None, None]
144+
145+
[ctx_name, filepath, rel_import_path] = file_info
146+
147+
mod = ModuleType(module_name)
148+
global_ctx = GlobalContext(
149+
ctx_name, global_sym_table=mod.__dict__, manager=self.manager, rel_import_path=rel_import_path
150+
)
151+
global_ctx.set_auto_start(True)
152+
error_ctx = await self.manager.load_file(filepath, global_ctx)
153+
if error_ctx:
154+
_LOGGER.debug(
155+
"module_import: failed to load module %s, ctx = %s, path = %s",
156+
module_name,
157+
ctx_name,
158+
filepath,
159+
)
160+
return [None, error_ctx]
161+
global_ctx.module = mod
162+
_LOGGER.debug("module_import: imported %s, ctx = %s, mod = %s", module_name, ctx_name, mod)
163+
return [mod, None]
92164

93165

94166
class GlobalContextMgr:
@@ -185,7 +257,7 @@ def new_name(cls, root):
185257

186258
@classmethod
187259
async def load_file(cls, filepath, global_ctx):
188-
"""Load, parse and run the given script file."""
260+
"""Load, parse and run the given script file; returns error ast_ctx on error, or None if ok."""
189261

190262
def read_file(path):
191263
try:
@@ -201,7 +273,7 @@ def read_file(path):
201273
source = await Function.hass.async_add_executor_job(read_file, filepath)
202274

203275
if source is None:
204-
return
276+
return False
205277

206278
ast_ctx = AstEval(global_ctx.get_name(), global_ctx)
207279
Function.install_ast_funcs(ast_ctx)
@@ -210,11 +282,12 @@ def read_file(path):
210282
exc = ast_ctx.get_exception_long()
211283
ast_ctx.get_logger().error(exc)
212284
global_ctx.stop()
213-
return
285+
return ast_ctx
214286
await ast_ctx.eval()
215287
exc = ast_ctx.get_exception_long()
216288
if exc is not None:
217289
ast_ctx.get_logger().error(exc)
218290
global_ctx.stop()
219-
return
291+
return ast_ctx
220292
cls.set(global_ctx.get_name(), global_ctx)
293+
return None

0 commit comments

Comments
 (0)