Skip to content

Commit 2aaac02

Browse files
committed
support modules and apps subfolders; pyscript.config is yaml config
1 parent cf61dbe commit 2aaac02

File tree

10 files changed

+162
-75
lines changed

10 files changed

+162
-75
lines changed

custom_components/pyscript/__init__.py

Lines changed: 44 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@
77

88
import voluptuous as vol
99

10+
from homeassistant.config import async_hass_config_yaml, async_process_component_config
1011
from homeassistant.const import (
1112
EVENT_HOMEASSISTANT_STARTED,
1213
EVENT_HOMEASSISTANT_STOP,
1314
EVENT_STATE_CHANGED,
1415
SERVICE_RELOAD,
1516
)
17+
from homeassistant.exceptions import HomeAssistantError
1618
import homeassistant.helpers.config_validation as cv
17-
from homeassistant.loader import bind_hass
19+
from homeassistant.loader import async_get_integration, bind_hass
1820

1921
from .const import DOMAIN, FOLDER, LOGGER_PATH, SERVICE_JUPYTER_KERNEL_START
2022
from .eval import AstEval
@@ -30,7 +32,11 @@
3032
CONF_ALLOW_ALL_IMPORTS = "allow_all_imports"
3133

3234
CONFIG_SCHEMA = vol.Schema(
33-
{DOMAIN: vol.Schema({vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): cv.boolean})},
35+
{
36+
DOMAIN: vol.Schema(
37+
{vol.Optional(CONF_ALLOW_ALL_IMPORTS, default=False): cv.boolean}, extra=vol.ALLOW_EXTRA,
38+
)
39+
},
3440
extra=vol.ALLOW_EXTRA,
3541
)
3642

@@ -44,37 +50,51 @@ async def async_setup(hass, config):
4450
State.register_functions()
4551
GlobalContextMgr.init()
4652

47-
path = hass.config.path(FOLDER)
53+
pyscript_folder = hass.config.path(FOLDER)
4854

4955
def check_isdir(path):
5056
return os.path.isdir(path)
5157

52-
if not await hass.async_add_executor_job(check_isdir, path):
58+
if not await hass.async_add_executor_job(check_isdir, pyscript_folder):
5359
_LOGGER.error("Folder %s not found in configuration folder", FOLDER)
5460
return False
5561

5662
hass.data.setdefault(DOMAIN, {})
5763
hass.data[DOMAIN]["allow_all_imports"] = config[DOMAIN].get(CONF_ALLOW_ALL_IMPORTS)
5864

59-
await compile_scripts(hass)
65+
State.set_pyscript_config(config.get(DOMAIN, {}))
6066

61-
_LOGGER.debug("adding reload handler")
67+
await load_scripts(hass)
6268

6369
async def reload_scripts_handler(call):
6470
"""Handle reload service calls."""
65-
_LOGGER.debug("stopping triggers and services, reloading scripts, and restarting")
71+
_LOGGER.debug("reload: yaml, reloading scripts, and restarting")
72+
73+
try:
74+
conf = await async_hass_config_yaml(hass)
75+
except HomeAssistantError as err:
76+
_LOGGER.error(err)
77+
return
78+
79+
integration = await async_get_integration(hass, DOMAIN)
80+
81+
config = await async_process_component_config(hass, conf, integration)
82+
83+
# GlobalContext.global_sym_table_add("pyscript.config", config.get(DOMAIN, {}))
84+
State.set_pyscript_config(config.get(DOMAIN, {}))
6685

6786
ctx_delete = {}
6887
for global_ctx_name, global_ctx in GlobalContextMgr.items():
69-
if not global_ctx_name.startswith("file."):
88+
idx = global_ctx_name.find(".")
89+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps", "modules"}:
7090
continue
7191
global_ctx.stop()
7292
global_ctx.set_auto_start(False)
7393
ctx_delete[global_ctx_name] = global_ctx
7494
for global_ctx_name, global_ctx in ctx_delete.items():
7595
await GlobalContextMgr.delete(global_ctx_name)
7696

77-
await compile_scripts(hass)
97+
await load_scripts(hass)
7898

7999
for global_ctx_name, global_ctx in GlobalContextMgr.items():
80100
if not global_ctx_name.startswith("file."):
@@ -88,7 +108,7 @@ async def jupyter_kernel_start(call):
88108
_LOGGER.debug("service call to jupyter_kernel_start: %s", call.data)
89109

90110
global_ctx_name = GlobalContextMgr.new_name("jupyter_")
91-
global_ctx = GlobalContext(global_ctx_name, hass, global_sym_table={})
111+
global_ctx = GlobalContext(global_ctx_name, global_sym_table={}, manager=GlobalContextMgr)
92112
global_ctx.set_auto_start(True)
93113

94114
GlobalContextMgr.set(global_ctx_name, global_ctx)
@@ -147,44 +167,25 @@ async def stop_triggers(event):
147167

148168

149169
@bind_hass
150-
async def compile_scripts(hass):
151-
"""Compile all python scripts in FOLDER."""
152-
153-
path = hass.config.path(FOLDER)
170+
async def load_scripts(hass):
171+
"""Load all python scripts in FOLDER."""
154172

155-
_LOGGER.debug("compile_scripts: path = %s", path)
173+
load_paths = [hass.config.path(FOLDER) + "/apps", hass.config.path(FOLDER)]
156174

157-
def glob_files(path, match):
158-
return glob.iglob(os.path.join(path, match))
175+
_LOGGER.debug("load_scripts: load_paths = %s", load_paths)
159176

160-
def read_file(path):
161-
with open(path) as file_desc:
162-
source = file_desc.read()
163-
return source
177+
def glob_files(load_paths, match):
178+
source_files = []
179+
for path in load_paths:
180+
source_files += sorted(glob.glob(os.path.join(path, match)))
181+
return source_files
164182

165-
source_files = await hass.async_add_executor_job(glob_files, path, "*.py")
183+
source_files = await hass.async_add_executor_job(glob_files, load_paths, "*.py")
166184

167-
for file in sorted(source_files):
168-
_LOGGER.debug("reading and parsing %s", file)
169-
name = os.path.splitext(os.path.basename(file))[0]
170-
source = await hass.async_add_executor_job(read_file, file)
185+
for source_file in sorted(source_files):
186+
name = os.path.splitext(os.path.basename(source_file))[0]
171187

172-
global_ctx_name = f"file.{name}"
173-
global_ctx = GlobalContext(global_ctx_name, hass, global_sym_table={})
188+
global_ctx = GlobalContext(f"file.{name}", global_sym_table={}, manager=GlobalContextMgr)
174189
global_ctx.set_auto_start(False)
175190

176-
ast_ctx = AstEval(global_ctx_name, global_ctx)
177-
Function.install_ast_funcs(ast_ctx)
178-
179-
if not ast_ctx.parse(source, filename=file):
180-
exc = ast_ctx.get_exception_long()
181-
ast_ctx.get_logger().error(exc)
182-
global_ctx.stop()
183-
continue
184-
await ast_ctx.eval()
185-
exc = ast_ctx.get_exception_long()
186-
if exc is not None:
187-
ast_ctx.get_logger().error(exc)
188-
global_ctx.stop()
189-
continue
190-
GlobalContextMgr.set(global_ctx_name, global_ctx)
191+
await GlobalContextMgr.load_file(source_file, global_ctx)

custom_components/pyscript/eval.py

Lines changed: 20 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -691,9 +691,7 @@ def __init__(self, name, global_ctx, logger_name=None):
691691
self.logger_handlers = set()
692692
self.logger = None
693693
self.set_logger_name(logger_name if logger_name is not None else self.name)
694-
self.allow_all_imports = (
695-
Function.hass.data[DOMAIN]["allow_all_imports"] if global_ctx.hass is not None else False
696-
)
694+
self.allow_all_imports = Function.hass.data.get(DOMAIN, {}).get("allow_all_imports", False)
697695

698696
async def ast_not_implemented(self, arg, *args):
699697
"""Raise NotImplementedError exception for unimplemented AST types."""
@@ -734,22 +732,26 @@ async def ast_module(self, arg):
734732
async def ast_import(self, arg):
735733
"""Execute import."""
736734
for imp in arg.names:
737-
if not self.allow_all_imports and imp.name not in ALLOWED_IMPORTS:
738-
raise ModuleNotFoundError(f"import of {imp.name} not allowed")
739-
if imp.name not in sys.modules:
740-
mod = await Function.hass.async_add_executor_job(importlib.import_module, imp.name)
741-
else:
742-
mod = sys.modules[imp.name]
735+
mod = await self.global_ctx.module_import(imp.name)
736+
if not mod:
737+
if not self.allow_all_imports and imp.name not in ALLOWED_IMPORTS:
738+
raise ModuleNotFoundError(f"import of {imp.name} not allowed")
739+
if imp.name not in sys.modules:
740+
mod = await Function.hass.async_add_executor_job(importlib.import_module, imp.name)
741+
else:
742+
mod = sys.modules[imp.name]
743743
self.sym_table[imp.name if imp.asname is None else imp.asname] = mod
744744

745745
async def ast_importfrom(self, arg):
746746
"""Execute from X import Y."""
747-
if not self.allow_all_imports and arg.module not in ALLOWED_IMPORTS:
748-
raise ModuleNotFoundError(f"import from {arg.module} not allowed")
749-
if arg.module not in sys.modules:
750-
mod = await Function.hass.async_add_executor_job(importlib.import_module, arg.module)
751-
else:
752-
mod = sys.modules[arg.module]
747+
mod = await self.global_ctx.module_import(arg.module)
748+
if not mod:
749+
if not self.allow_all_imports and arg.module not in ALLOWED_IMPORTS:
750+
raise ModuleNotFoundError(f"import from {arg.module} not allowed")
751+
if arg.module not in sys.modules:
752+
mod = await Function.hass.async_add_executor_job(importlib.import_module, arg.module)
753+
else:
754+
mod = sys.modules[arg.module]
753755
for imp in arg.names:
754756
if imp.name == "*":
755757
for name, value in mod.__dict__.items():
@@ -1405,12 +1407,12 @@ async def loopvar_scope_save(self, generators):
14051407
# looping variables are in their own implicit nested scope, so save/restore
14061408
# variables in the current scope with the same names
14071409
#
1408-
vars = set()
1410+
lvars = set()
14091411
for gen in generators:
14101412
await self.get_names(
1411-
ast.Assign(targets=[gen.target], value=ast.Constant(value=None)), local_names=vars
1413+
ast.Assign(targets=[gen.target], value=ast.Constant(value=None)), local_names=lvars
14121414
)
1413-
return vars, {var: self.sym_table[var] for var in vars if var in self.sym_table}
1415+
return lvars, {var: self.sym_table[var] for var in lvars if var in self.sym_table}
14141416

14151417
async def loopvar_scope_restore(self, var_names, save_vars):
14161418
"""Restore current scope variables that match looping target vars."""

custom_components/pyscript/global_ctx.py

Lines changed: 65 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
"""Global context handling."""
22

33
import logging
4+
import os
5+
from types import ModuleType
46

5-
from .const import LOGGER_PATH
7+
from .const import FOLDER, LOGGER_PATH
8+
from .eval import AstEval
69
from .function import Function
710
from .trigger import TrigInfo
811

@@ -12,15 +15,16 @@
1215
class GlobalContext:
1316
"""Define class for global variables and trigger context."""
1417

15-
def __init__(self, name, hass, global_sym_table=None):
18+
def __init__(self, name, global_sym_table=None, manager=None):
1619
"""Initialize GlobalContext."""
1720
self.name = name
18-
self.hass = hass
1921
self.global_sym_table = global_sym_table if global_sym_table else {}
2022
self.triggers = set()
2123
self.triggers_delay_start = set()
2224
self.logger = logging.getLogger(LOGGER_PATH + "." + name)
25+
self.manager = manager
2326
self.auto_start = False
27+
self.module = None
2428

2529
def trigger_register(self, func):
2630
"""Register a trigger function; return True if start now."""
@@ -64,6 +68,28 @@ def get_trig_info(self, name, trig_args):
6468
"""Return a new trigger info instance with the given args."""
6569
return TrigInfo(name, trig_args, self)
6670

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
92+
6793

6894
class GlobalContextMgr:
6995
"""Define class for all global contexts."""
@@ -156,3 +182,39 @@ def new_name(cls, root):
156182
cls.name_seq += 1
157183
if name not in cls.contexts:
158184
return name
185+
186+
@classmethod
187+
async def load_file(cls, filepath, global_ctx):
188+
"""Load, parse and run the given script file."""
189+
190+
def read_file(path):
191+
try:
192+
with open(path) as file_desc:
193+
source = file_desc.read()
194+
return source
195+
except Exception as exc:
196+
_LOGGER.error("%s", exc)
197+
return None
198+
199+
_LOGGER.debug("reading and parsing %s", filepath)
200+
201+
source = await Function.hass.async_add_executor_job(read_file, filepath)
202+
203+
if source is None:
204+
return
205+
206+
ast_ctx = AstEval(global_ctx.get_name(), global_ctx)
207+
Function.install_ast_funcs(ast_ctx)
208+
209+
if not ast_ctx.parse(source, filename=filepath):
210+
exc = ast_ctx.get_exception_long()
211+
ast_ctx.get_logger().error(exc)
212+
global_ctx.stop()
213+
return
214+
await ast_ctx.eval()
215+
exc = ast_ctx.get_exception_long()
216+
if exc is not None:
217+
ast_ctx.get_logger().error(exc)
218+
global_ctx.stop()
219+
return
220+
cls.set(global_ctx.get_name(), global_ctx)

custom_components/pyscript/state.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ class State:
2929
#
3030
notify_var_last = {}
3131

32+
#
33+
# pyscript yaml configuration
34+
#
35+
pyscript_config = {}
36+
3237
def __init__(self):
3338
"""Warn on State instantiation."""
3439
_LOGGER.error("State class is not meant to be instantiated")
@@ -170,11 +175,22 @@ async def names(cls, domain=None):
170175

171176
@classmethod
172177
def register_functions(cls):
173-
"""Register state functions."""
178+
"""Register state functions and config variable."""
174179
functions = {
175180
"state.get": cls.get,
176181
"state.set": cls.set,
177182
"state.names": cls.names,
178183
"state.get_attr": cls.get_attr,
184+
"pyscript.config": cls.pyscript_config,
179185
}
180186
Function.register(functions)
187+
188+
@classmethod
189+
def set_pyscript_config(cls, config):
190+
"""Set pyscript yaml config."""
191+
#
192+
# have to update inplace, since dist is already used as value
193+
#
194+
cls.pyscript_config.clear()
195+
for name, value in config.items():
196+
cls.pyscript_config[name] = value

tests/test_decorator_errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ async def setup_script(hass, notify_q, now, source):
2828
with patch("homeassistant.loader.async_get_integration", return_value=integration), patch(
2929
"custom_components.pyscript.os.path.isdir", return_value=True
3030
), patch("custom_components.pyscript.glob.iglob", return_value=scripts), patch(
31-
"custom_components.pyscript.open", mock_open(read_data=source), create=True,
31+
"custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,
3232
), patch(
3333
"custom_components.pyscript.trigger.dt_now", return_value=now
3434
):

tests/test_function.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -114,7 +114,7 @@ async def setup_script(hass, notify_q, now, source):
114114
with patch("homeassistant.loader.async_get_integration", return_value=integration), patch(
115115
"custom_components.pyscript.os.path.isdir", return_value=True
116116
), patch("custom_components.pyscript.glob.iglob", return_value=scripts), patch(
117-
"custom_components.pyscript.open", mock_open(read_data=source), create=True,
117+
"custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,
118118
), patch(
119119
"custom_components.pyscript.trigger.dt_now", return_value=now
120120
):

0 commit comments

Comments
 (0)