Skip to content

Commit 1ee8497

Browse files
committed
autoloading of all scripts recursively below pyscript/scripts; fixes #97
1 parent cfbada0 commit 1ee8497

File tree

8 files changed

+156
-43
lines changed

8 files changed

+156
-43
lines changed

custom_components/pyscript/__init__.py

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,10 @@
3232
)
3333
from .eval import AstEval
3434
from .event import Event
35-
from .mqtt import Mqtt
3635
from .function import Function
3736
from .global_ctx import GlobalContext, GlobalContextMgr
3837
from .jupyter_kernel import Kernel
38+
from .mqtt import Mqtt
3939
from .requirements import install_requirements
4040
from .state import State, StateVal
4141
from .trigger import TrigTime
@@ -98,7 +98,7 @@ def start_global_contexts(global_ctx_only=None):
9898
start_list = []
9999
for global_ctx_name, global_ctx in GlobalContextMgr.items():
100100
idx = global_ctx_name.find(".")
101-
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps"}:
101+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps", "scripts"}:
102102
continue
103103
if global_ctx_only is not None:
104104
if global_ctx_name != global_ctx_only and not global_ctx_name.startswith(global_ctx_only + "."):
@@ -252,7 +252,7 @@ async def unload_scripts(global_ctx_only=None, unload_all=False):
252252
for global_ctx_name, global_ctx in GlobalContextMgr.items():
253253
if not unload_all:
254254
idx = global_ctx_name.find(".")
255-
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps", "modules"}:
255+
if idx < 0 or global_ctx_name[0:idx] not in {"file", "apps", "modules", "scripts"}:
256256
continue
257257
if global_ctx_only is not None:
258258
if global_ctx_name != global_ctx_only and not global_ctx_name.startswith(global_ctx_only + "."):
@@ -273,33 +273,41 @@ def glob_files(load_paths, data):
273273
source_files = []
274274
apps_config = data.get("apps", None)
275275
for path, match, check_config in load_paths:
276-
for this_path in sorted(glob.glob(os.path.join(pyscript_dir, path, match))):
276+
for this_path in sorted(glob.glob(os.path.join(pyscript_dir, path, match), recursive=True)):
277277
rel_import_path = None
278-
elts = this_path.split("/")
279-
if match.find("/") < 0:
280-
# last entry without the .py
281-
mod_name = elts[-1][0:-3]
282-
else:
283-
# 2nd last entry
284-
mod_name = elts[-2]
285-
rel_import_path = f"{path}/mod_name"
278+
rel_path = this_path
279+
if rel_path.startswith(pyscript_dir):
280+
rel_path = rel_path[len(pyscript_dir) :]
281+
if rel_path.startswith("/"):
282+
rel_path = rel_path[1:]
283+
if rel_path[0] == "#" or rel_path.find("/#") >= 0:
284+
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("/", ".")
286290
if path == "":
287291
global_ctx_name = f"file.{mod_name}"
288292
fq_mod_name = mod_name
289293
else:
290-
global_ctx_name = f"{path}.{mod_name}"
291-
fq_mod_name = global_ctx_name
294+
fq_mod_name = global_ctx_name = mod_name
295+
i = fq_mod_name.find(".")
296+
if i >= 0:
297+
fq_mod_name = fq_mod_name[i + 1 :]
292298
if check_config:
293-
if not isinstance(apps_config, dict) or mod_name not in apps_config:
299+
if not isinstance(apps_config, dict) or fq_mod_name not in apps_config:
294300
_LOGGER.debug("load_scripts: skipping %s because config not present", this_path)
295301
continue
296302
source_files.append([global_ctx_name, this_path, rel_import_path, fq_mod_name])
303+
297304
return source_files
298305

299306
load_paths = [
300307
["apps", "*.py", True],
301308
["apps", "*/__init__.py", True],
302309
["", "*.py", False],
310+
["scripts", "**/*.py", False],
303311
]
304312

305313
source_files = await hass.async_add_executor_job(glob_files, load_paths, data)

docs/new_features.rst

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@ The new features since 1.0.0 in master include:
3434
successful trigger. Setting this optional parameter makes state triggers edge triggered (ie,
3535
triggers only on transition from ``False`` to ``True``), instead of the default level trigger (ie,
3636
only has to evaluate to ``True``). Proposed by @tchef69 (#89).
37-
- ``del`` and new function ``state.delete()` can delete state variables and state variable attributes
37+
- Adding new decorator ``@mqtt_trigger`` by @dlashua (#98, #105).
38+
- All .py files below the pyscript/scripts directory are autoloaded, recursively into all subdirectories.
39+
Any file name or directory starting with ``#`` is skipped, which is an in-place way of disabling
40+
a specific file or directory tree (#97).
41+
- ``del`` and new function ``state.delete()` can delete state variables and state variable attributes.
3842
3943
Bug fixes since 1.0.0 in master include:
4044
41-
- state setting now copies the attributes, to avoid a strange ``MappingProxyType`` recursion error
45+
- State setting now copies the attributes, to avoid a strange ``MappingProxyType`` recursion error
4246
inside HASS, reported by @github392 (#87).
43-
- the deprecated function ``state.get_attr`` was missing an ``await``, which causes an exception; in 1.0.0 use
47+
- The deprecated function ``state.get_attr`` was missing an ``await``, which causes an exception; in 1.0.0 use
4448
``state.getattr``, reported and fixed by @dlashua (#88).
45-
- the ``packaging`` module is installed if not found, since certain HASS configurations might not include it;
49+
- The ``packaging`` module is installed if not found, since certain HASS configurations might not include it;
4650
fixed by @raman325 (#90, #91).

docs/reference.rst

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ At startup, pyscript loads the following files. It also unloads and reloads thes
5858
the ``pyscript.reload`` service is called, which also reloads the ``yaml`` configuration.
5959

6060
``<config>/pyscript/*.py``
61-
all files with a ``.py`` suffix are autoloaded
61+
all files with a ``.py`` suffix are autoloaded.
62+
63+
``<config>/pyscript/scripts/**/*.py``
64+
all files with a ``.py`` suffix below the ``scripts`` directory, recursively to any depth,
65+
are autoloaded. This is useful for organizing your scripts into subdirectories that are
66+
related in some way.
6267

6368
``<config>/pyscript/apps/<app_name>.py``
6469
all files in the ``apps`` subdirectory with a ``.py`` suffix are autoloaded, provided ``app_name``
@@ -71,6 +76,12 @@ the ``pyscript.reload`` service is called, which also reloads the ``yaml`` confi
7176
This form is most convenient for sharing pyscript code, since all the files for one
7277
application are stored in its own directory.
7378

79+
Any file name that starts with ``#`` is not loaded, and similarly scripts anywhere below a directory
80+
name that starts with ``#``, are not loaded. That's a convenient way to disable a specific script or
81+
entire directory below ``scripts`` - you can simply rename it with or without the leading ``#`` to
82+
disable or enable it at the next reload. Think of it as "commenting" the file name, rather than
83+
having to delete it or move it outside the pyscript directory.
84+
7485
Like regular Python, functions within one source file can call each other, and can share global
7586
variables (if necessary), but just within that one file. Each file has its own separate global
7687
context. Each Jupyter session also has its own separate global context, so functions, triggers,
@@ -1027,7 +1038,8 @@ module or package that is explicitly imported). In normal use you don’t need t
10271038
contexts. But for interactive debugging and development, you might want your Jupyter session to
10281039
access variables and functions defined in a script file.
10291040

1030-
Here is the naming convention for each file's global context:
1041+
Here is the naming convention for each file's global context (upper case mean any value;
1042+
lower case are actual fixed names):
10311043

10321044
======================================= ===========================
10331045
pyscript file path global context name
@@ -1039,6 +1051,9 @@ Here is the naming convention for each file's global context:
10391051
``pyscript/apps/APP.py`` ``apps.APP``
10401052
``pyscript/apps/APP/__init__.py`` ``apps.APP.__init__``
10411053
``pyscript/apps/APP/FILE.py`` ``apps.APP.FILE``
1054+
``pyscript/scripts/FILE.py`` ``scripts.FILE``
1055+
``pyscript/scripts/DIR1/FILE.py`` ``scripts.DIR1.FILE``
1056+
``pyscript/scripts/DIR1/DIR2/FILE.py`` ``scripts.DIR1.DIR2.FILE``
10421057
======================================= ===========================
10431058

10441059
The logging path uses the global context name, so you can customize logging verbosity for each

tests/test_apps_modules.py

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,8 @@ def func10():
7474
f"{conf_dir}/modules/xyz2/__init__.py": """
7575
from .other import f_minus, other_name
7676
77+
log.info(f"modules/xyz2 global_ctx={pyscript.get_global_ctx()};")
78+
7779
x = 99
7880
7981
def f_add(a, b):
@@ -101,6 +103,41 @@ def other_name():
101103
f"{conf_dir}/modules/bad_module.py": """
102104
def func12()
103105
pass
106+
""",
107+
#
108+
# this script file should auto-load
109+
#
110+
f"{conf_dir}/scripts/func13.py": """
111+
@service
112+
def func13():
113+
pass
114+
""",
115+
#
116+
# this script file should auto-load
117+
#
118+
f"{conf_dir}/scripts/a/b/c/d/func14.py": """
119+
@service
120+
def func14():
121+
pass
122+
123+
log.info(f"func14 global_ctx={pyscript.get_global_ctx()};")
124+
125+
""",
126+
#
127+
# this script file should not auto-load
128+
#
129+
f"{conf_dir}/scripts/a/b/c/d/#func15.py": """
130+
@service
131+
def func15():
132+
pass
133+
""",
134+
#
135+
# this script file should not auto-load
136+
#
137+
f"{conf_dir}/scripts/#a/b/c/d/func15.py": """
138+
@service
139+
def func15():
140+
pass
104141
""",
105142
}
106143

@@ -114,10 +151,10 @@ def isfile_side_effect(arg):
114151
def glob_side_effect(path, recursive=None):
115152
result = []
116153
path_re = path.replace("*", "[^/]*").replace(".", "\\.")
154+
path_re = path_re.replace("[^/]*[^/]*/", ".*")
117155
for this_path in file_contents:
118156
if re.match(path_re, this_path):
119157
result.append(this_path)
120-
print(f"glob_side_effect: path={path}, path_re={path_re}, result={result}")
121158
return result
122159

123160
conf = {"apps": {"world": {}}}
@@ -126,7 +163,7 @@ def glob_side_effect(path, recursive=None):
126163
) as mock_glob, patch("custom_components.pyscript.global_ctx.open", mock_open), patch(
127164
"homeassistant.config.load_yaml_config_file", return_value={"pyscript": conf}
128165
), patch(
129-
"os.path.isfile"
166+
"custom_components.pyscript.os.path.isfile"
130167
) as mock_isfile:
131168
mock_isfile.side_effect = isfile_side_effect
132169
mock_glob.side_effect = glob_side_effect
@@ -145,7 +182,9 @@ async def state_changed(event):
145182

146183
assert not hass.services.has_service("pyscript", "func10")
147184
assert not hass.services.has_service("pyscript", "func11")
148-
assert not hass.services.has_service("pyscript", "func12")
185+
assert hass.services.has_service("pyscript", "func13")
186+
assert hass.services.has_service("pyscript", "func14")
187+
assert not hass.services.has_service("pyscript", "func15")
149188

150189
await hass.services.async_call("pyscript", "func1", {})
151190
ret = await wait_until_done(notify_q)
@@ -155,5 +194,7 @@ async def state_changed(event):
155194
ret = await wait_until_done(notify_q)
156195
assert literal_eval(ret) == [99, "xyz2", "xyz2.other", 1 + 5, 3 * 6, 10 + 30, 50 - 30]
157196

197+
assert "modules/xyz2 global_ctx=modules.xyz2.__init__;" in caplog.text
198+
assert "func14 global_ctx=scripts.a.b.c.d.func14;" in caplog.text
158199
assert "ModuleNotFoundError: import of no_such_package not allowed" in caplog.text
159200
assert "SyntaxError: invalid syntax (bad_module.py, line 2)" in caplog.text

tests/test_decorator_errors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
async def setup_script(hass, notify_q, now, source):
1515
"""Initialize and load the given pyscript."""
1616
scripts = [
17-
"/some/config/dir/pyscripts/hello.py",
17+
"/hello.py",
1818
]
1919

2020
with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch(

tests/test_function.py

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,14 @@
33
from ast import literal_eval
44
import asyncio
55
from datetime import datetime as dt
6+
import re
67

7-
from custom_components.pyscript.const import CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL, DOMAIN
8+
from custom_components.pyscript.const import CONF_ALLOW_ALL_IMPORTS, CONF_HASS_IS_GLOBAL, DOMAIN, FOLDER
89
from custom_components.pyscript.function import Function
910
import custom_components.pyscript.trigger as trigger
11+
from mock_open import MockOpen
1012
import pytest
11-
from pytest_homeassistant_custom_component.async_mock import MagicMock, Mock, mock_open, patch
13+
from pytest_homeassistant_custom_component.async_mock import MagicMock, Mock, patch
1214

1315
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED
1416
from homeassistant.core import Context
@@ -100,21 +102,42 @@ async def test_service_completions(root, expected, hass, services): # pylint: d
100102

101103
async def setup_script(hass, notify_q, notify_q2, now, source, config=None):
102104
"""Initialize and load the given pyscript."""
103-
scripts = [
104-
"/some/config/dir/pyscripts/hello.py",
105-
]
105+
106+
conf_dir = hass.config.path(FOLDER)
107+
108+
file_contents = {f"{conf_dir}/hello.py": source}
109+
110+
mock_open = MockOpen()
111+
for key, value in file_contents.items():
112+
mock_open[key].read_data = value
113+
114+
def isfile_side_effect(arg):
115+
return arg in file_contents
116+
117+
def glob_side_effect(path, recursive=None):
118+
result = []
119+
path_re = path.replace("*", "[^/]*").replace(".", "\\.")
120+
path_re = path_re.replace("[^/]*[^/]*/", ".*")
121+
for this_path in file_contents:
122+
if re.match(path_re, this_path):
123+
result.append(this_path)
124+
return result
106125

107126
if not config:
108127
config = {DOMAIN: {CONF_ALLOW_ALL_IMPORTS: True}}
109128
with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch(
110-
"custom_components.pyscript.glob.iglob", return_value=scripts
111-
), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,), patch(
129+
"custom_components.pyscript.glob.iglob"
130+
) as mock_glob, patch("custom_components.pyscript.global_ctx.open", mock_open), patch(
112131
"custom_components.pyscript.trigger.dt_now", return_value=now
113132
), patch(
114133
"homeassistant.config.load_yaml_config_file", return_value=config
115134
), patch(
116135
"custom_components.pyscript.install_requirements", return_value=None,
117-
):
136+
), patch(
137+
"custom_components.pyscript.os.path.isfile"
138+
) as mock_isfile:
139+
mock_isfile.side_effect = isfile_side_effect
140+
mock_glob.side_effect = glob_side_effect
118141
assert await async_setup_component(hass, "pyscript", config)
119142

120143
#

tests/test_init.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ async def setup_script(hass, notify_q, now, source):
2323
"""Initialize and load the given pyscript."""
2424

2525
scripts = [
26-
"/some/config/dir/pyscript/hello.py",
26+
"/hello.py",
2727
]
2828

2929
with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch(
@@ -437,7 +437,7 @@ def func5(var_name=None, value=None):
437437
# now reload the other source file
438438
#
439439
scripts = [
440-
"/some/config/dir/pyscript/hello.py",
440+
"/hello.py",
441441
]
442442

443443
with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch(

tests/test_jupyter.py

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import hashlib
77
import hmac
88
import json
9+
import re
910
import uuid
1011

11-
from custom_components.pyscript.const import DOMAIN
12+
from custom_components.pyscript.const import DOMAIN, FOLDER
1213
from custom_components.pyscript.jupyter_kernel import ZmqSocket
1314
import custom_components.pyscript.trigger as trigger
14-
from pytest_homeassistant_custom_component.async_mock import mock_open, patch
15+
from mock_open import MockOpen
16+
from pytest_homeassistant_custom_component.async_mock import patch
1517

1618
from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP
1719
from homeassistant.setup import async_setup_component
@@ -107,19 +109,39 @@ def encode(msg):
107109
async def setup_script(hass, now, source, no_connect=False):
108110
"""Initialize and load the given pyscript."""
109111

110-
scripts = [
111-
"/some/config/dir/pyscripts/hello.py",
112-
]
112+
conf_dir = hass.config.path(FOLDER)
113+
114+
file_contents = {f"{conf_dir}/hello.py": source}
115+
116+
mock_open = MockOpen()
117+
for key, value in file_contents.items():
118+
mock_open[key].read_data = value
119+
120+
def isfile_side_effect(arg):
121+
return arg in file_contents
122+
123+
def glob_side_effect(path, recursive=None):
124+
result = []
125+
path_re = path.replace("*", "[^/]*").replace(".", "\\.")
126+
path_re = path_re.replace("[^/]*[^/]*/", ".*")
127+
for this_path in file_contents:
128+
if re.match(path_re, this_path):
129+
result.append(this_path)
130+
return result
113131

114132
with patch("custom_components.pyscript.os.path.isdir", return_value=True), patch(
115-
"custom_components.pyscript.glob.iglob", return_value=scripts
116-
), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source), create=True,), patch(
133+
"custom_components.pyscript.glob.iglob"
134+
) as mock_glob, patch("custom_components.pyscript.global_ctx.open", mock_open), patch(
117135
"custom_components.pyscript.trigger.dt_now", return_value=now
118136
), patch(
119137
"homeassistant.config.load_yaml_config_file", return_value={}
120138
), patch(
121139
"custom_components.pyscript.install_requirements", return_value=None,
122-
):
140+
), patch(
141+
"custom_components.pyscript.os.path.isfile"
142+
) as mock_isfile:
143+
mock_isfile.side_effect = isfile_side_effect
144+
mock_glob.side_effect = glob_side_effect
123145
assert await async_setup_component(hass, "pyscript", {DOMAIN: {}})
124146

125147
#

0 commit comments

Comments
 (0)