diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index b602ebe..8dcb7f5 100644 --- a/custom_components/pyscript/__init__.py +++ b/custom_components/pyscript/__init__.py @@ -11,6 +11,7 @@ import shutil import time import traceback +from types import ModuleType from typing import Any import voluptuous as vol @@ -74,6 +75,16 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: PYSCRIPT_SCHEMA}, extra=vol.ALLOW_EXTRA) +def _relative_script_path(path: str, root: str, path_module: ModuleType = os.path) -> str: + """Return a slash-separated path relative to the pyscript root.""" + return path_module.relpath(path, root).replace(path_module.sep, "/") + + +def _is_requirements_file(path: str, path_module: ModuleType = os.path) -> bool: + """Match a requirements file using the host path rules.""" + return path_module.basename(path) == REQUIREMENTS_FILE + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Component setup, run import config flow for each entry in config.""" await restore_state(hass) @@ -225,9 +236,11 @@ def check_event(event: FileSystemEvent, do_reload: bool) -> bool: # also check dest_path: atomic saves (tempfile + os.replace) surface # as a moved event whose src_path is an unrelated temp name paths = (event.src_path, event.dest_path) - for valid_suffix in [".py", ".yaml", "/" + REQUIREMENTS_FILE]: + for valid_suffix in [".py", ".yaml"]: if any(path.endswith(valid_suffix) for path in paths): return True + if any(_is_requirements_file(path) for path in paths): + return True return do_reload while True: @@ -533,11 +546,11 @@ def glob_read_files( for path, match, check_config, autoload in load_paths: for this_path in sorted(glob.glob(os.path.join(pyscript_dir, path, match), recursive=True)): rel_import_path = None - rel_path = this_path - if rel_path.startswith(pyscript_dir): - rel_path = rel_path[len(pyscript_dir) :] - if rel_path.startswith("/"): - rel_path = rel_path[1:] + try: + rel_path = _relative_script_path(this_path, pyscript_dir) + except ValueError as exc: + _LOGGER.error("load_scripts: skipping %s due to exception %s", this_path, exc) + continue if rel_path[0] == "#" or rel_path.find("/#") >= 0: # skip "commented" files and directories continue diff --git a/tests/test_decorator_errors.py b/tests/test_decorator_errors.py index 94879ae..dbb38da 100644 --- a/tests/test_decorator_errors.py +++ b/tests/test_decorator_errors.py @@ -25,6 +25,9 @@ async def setup_script(hass, notify_q, now, source): with ( patch("custom_components.pyscript.os.path.isdir", return_value=True), patch("custom_components.pyscript.glob.iglob", return_value=scripts), + # This fixture deliberately uses a synthetic /hello.py for stable + # traceback assertions; emulate the relative name of an in-root file. + patch("custom_components.pyscript._relative_script_path", return_value="hello.py"), patch("custom_components.pyscript.global_ctx.open", mock_open(read_data=source)), patch("custom_components.pyscript.trigger.dt_now", return_value=now), patch("homeassistant.config.load_yaml_config_file", return_value={}), diff --git a/tests/test_init.py b/tests/test_init.py index 98cd0ac..eb17d2d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -3,12 +3,15 @@ from ast import literal_eval import asyncio from datetime import datetime as dt +import ntpath +import os import pathlib from unittest.mock import mock_open, patch import pytest from pytest_homeassistant.common import mock_restore_cache +import custom_components.pyscript as pyscript_component from custom_components.pyscript import restore_state, trigger from custom_components.pyscript.const import DOMAIN from custom_components.pyscript.event import Event @@ -25,7 +28,7 @@ async def setup_script(hass, notify_q, now, source, script_name="/hello.py"): """Initialize and load the given pyscript.""" - scripts = [script_name] + scripts = [f"{hass.config.path('pyscript')}{script_name}"] Function.hass = None @@ -71,6 +74,56 @@ async def wait_until_done(notify_q): return await asyncio.wait_for(notify_q.get(), timeout=4) +def test_relative_script_path_normalizes_windows_paths(): + """Script paths should derive portable context names on Windows.""" + assert ( + pyscript_component._relative_script_path( # pylint: disable=protected-access + r"C:\config\pyscript\apps\foo\__init__.py", r"C:\config\pyscript", ntpath + ) + == "apps/foo/__init__.py" + ) + + +def test_requirements_file_matches_windows_and_posix_paths(): + """Requirements events use path components, not a hard-coded separator.""" + # pylint: disable=protected-access + assert pyscript_component._is_requirements_file(r"C:\config\pyscript\requirements.txt", ntpath) + assert pyscript_component._is_requirements_file("/config/pyscript/requirements.txt") + assert not pyscript_component._is_requirements_file(r"C:\config\notrequirements.txt", ntpath) + + +@pytest.mark.asyncio +async def test_windows_paths_create_portable_context_names(pyscript, monkeypatch, caplog): + """Exercise the loader with Windows-style relative names without patching os.path.""" + original = pyscript_component._relative_script_path # pylint: disable=protected-access + + def windows_relative(path, root): + if path.endswith("cross_drive.py"): + raise ValueError("paths on different drives") + relative = os.path.relpath(path, root).replace(os.sep, "\\") + return original(ntpath.join(r"C:\config\pyscript", relative), r"C:\config\pyscript", ntpath) + + monkeypatch.setattr(pyscript_component, "_relative_script_path", windows_relative) + pyscript.yaml_config = {DOMAIN: {"apps": {"foo": {}}}} + pyscript.config = pyscript.yaml_config + pyscript.add_files( + { + "hello.py": "x = 1", + "apps/foo/__init__.py": "x = 1", + "scripts/nested/task.py": "x = 1", + "scripts/#disabled/skip.py": "x = 1", + "scripts/cross_drive.py": "x = 1", + } + ) + await pyscript.start() + + names = {name for name, _ in GlobalContextMgr.items()} + assert {"file.hello", "apps.foo", "scripts.nested.task"} <= names + assert not any("#disabled" in name for name in names) + assert "scripts.cross_drive" not in names + assert "paths on different drives" in caplog.text + + @pytest.mark.asyncio async def test_setup_makedirs_on_no_dir(hass, caplog): """Test setup calls os.makedirs when no dir found.""" @@ -149,7 +202,7 @@ def func1() """, ) assert "SyntaxError: expected ':'" in caplog.text - assert 'File "/hello.py", line 3' in caplog.text + assert f'File "{hass.config.path("pyscript")}/hello.py", line 3' in caplog.text @pytest.mark.asyncio @@ -165,7 +218,7 @@ async def test_syntax_error2(hass, caplog): """, ) assert "SyntaxError: invalid syntax" in caplog.text - assert 'File "/hello.py", line 2' in caplog.text + assert f'File "{hass.config.path("pyscript")}/hello.py", line 2' in caplog.text @pytest.mark.asyncio @@ -479,7 +532,7 @@ def func5(var_name=None, value=None): # now reload the other source file # scripts = [ - "/hello.py", + f"{hass.config.path('pyscript')}/hello.py", ] with (