From 10ed3191b05c67726f4f9be0b7ab83cbe22a0b5c Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sun, 20 Sep 2026 19:50:56 +0800 Subject: [PATCH 1/5] Fix Windows script path normalization --- custom_components/pyscript/__init__.py | 11 ++++++----- tests/test_init.py | 15 +++++++++++++++ 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index b602ebe..994935b 100644 --- a/custom_components/pyscript/__init__.py +++ b/custom_components/pyscript/__init__.py @@ -74,6 +74,11 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: PYSCRIPT_SCHEMA}, extra=vol.ALLOW_EXTRA) +def _relative_script_path(path: str, root: str) -> str: + """Return a slash-separated path relative to the pyscript root.""" + return os.path.relpath(path, root).replace(os.sep, "/") + + async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Component setup, run import config flow for each entry in config.""" await restore_state(hass) @@ -533,11 +538,7 @@ 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:] + rel_path = _relative_script_path(this_path, pyscript_dir) if rel_path[0] == "#" or rel_path.find("/#") >= 0: # skip "commented" files and directories continue diff --git a/tests/test_init.py b/tests/test_init.py index 98cd0ac..58e7fe1 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -3,12 +3,14 @@ from ast import literal_eval import asyncio from datetime import datetime as dt +import ntpath 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 @@ -71,6 +73,19 @@ 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(monkeypatch): + """Script paths should derive portable context names on Windows.""" + monkeypatch.setattr(pyscript_component.os.path, "relpath", ntpath.relpath) + monkeypatch.setattr(pyscript_component.os, "sep", "\\") + + assert ( + pyscript_component._relative_script_path( # pylint: disable=protected-access + r"C:\config\pyscript\hello.py", r"C:\config\pyscript" + ) + == "hello.py" + ) + + @pytest.mark.asyncio async def test_setup_makedirs_on_no_dir(hass, caplog): """Test setup calls os.makedirs when no dir found.""" From 06dc3e4f87f12bddf7ab46494d577650607141ec Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sat, 7 Nov 2026 12:26:31 +0800 Subject: [PATCH 2/5] fix: cover Windows script paths and requirements events --- custom_components/pyscript/__init__.py | 14 ++++++++--- tests/test_init.py | 34 +++++++++++++++++++++----- 2 files changed, 38 insertions(+), 10 deletions(-) diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index 994935b..9e72a38 100644 --- a/custom_components/pyscript/__init__.py +++ b/custom_components/pyscript/__init__.py @@ -74,9 +74,9 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: PYSCRIPT_SCHEMA}, extra=vol.ALLOW_EXTRA) -def _relative_script_path(path: str, root: str) -> str: +def _relative_script_path(path: str, root: str, path_module=os.path) -> str: """Return a slash-separated path relative to the pyscript root.""" - return os.path.relpath(path, root).replace(os.sep, "/") + return path_module.relpath(path, root).replace(path_module.sep, "/") async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: @@ -230,9 +230,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(os.path.basename(path) == REQUIREMENTS_FILE for path in paths): + return True return do_reload while True: @@ -538,7 +540,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 = _relative_script_path(this_path, pyscript_dir) + 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_init.py b/tests/test_init.py index 58e7fe1..aaacb94 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -4,6 +4,7 @@ import asyncio from datetime import datetime as dt import ntpath +import os import pathlib from unittest.mock import mock_open, patch @@ -73,19 +74,40 @@ 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(monkeypatch): +def test_relative_script_path_normalizes_windows_paths(): """Script paths should derive portable context names on Windows.""" - monkeypatch.setattr(pyscript_component.os.path, "relpath", ntpath.relpath) - monkeypatch.setattr(pyscript_component.os, "sep", "\\") - assert ( pyscript_component._relative_script_path( # pylint: disable=protected-access - r"C:\config\pyscript\hello.py", r"C:\config\pyscript" + r"C:\config\pyscript\apps\foo\__init__.py", r"C:\config\pyscript", ntpath ) - == "hello.py" + == "apps/foo/__init__.py" ) +@pytest.mark.asyncio +async def test_windows_paths_create_portable_context_names(pyscript, monkeypatch): + """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): + 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.add_files({ + "hello.py": "x = 1", + "apps/foo/__init__.py": "x = 1", + "scripts/nested/task.py": "x = 1", + "scripts/#disabled/skip.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) + + @pytest.mark.asyncio async def test_setup_makedirs_on_no_dir(hass, caplog): """Test setup calls os.makedirs when no dir found.""" From eb2c0aa429b01ccd27a99e42990acb2d86a1d28c Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sat, 7 Nov 2026 12:38:11 +0800 Subject: [PATCH 3/5] test: exercise loader paths and requirements matching --- custom_components/pyscript/__init__.py | 10 +++++-- tests/test_init.py | 37 ++++++++++++++++++-------- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/custom_components/pyscript/__init__.py b/custom_components/pyscript/__init__.py index 9e72a38..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,11 +75,16 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: PYSCRIPT_SCHEMA}, extra=vol.ALLOW_EXTRA) -def _relative_script_path(path: str, root: str, path_module=os.path) -> str: +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) @@ -233,7 +239,7 @@ def check_event(event: FileSystemEvent, do_reload: bool) -> bool: for valid_suffix in [".py", ".yaml"]: if any(path.endswith(valid_suffix) for path in paths): return True - if any(os.path.basename(path) == REQUIREMENTS_FILE for path in paths): + if any(_is_requirements_file(path) for path in paths): return True return do_reload diff --git a/tests/test_init.py b/tests/test_init.py index aaacb94..e33dd76 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -28,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 @@ -84,28 +84,43 @@ def test_relative_script_path_normalizes_windows_paths(): ) +def test_requirements_file_matches_windows_and_posix_paths(): + """Requirements events use path components, not a hard-coded separator.""" + 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): +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.add_files({ - "hello.py": "x = 1", - "apps/foo/__init__.py": "x = 1", - "scripts/nested/task.py": "x = 1", - "scripts/#disabled/skip.py": "x = 1", - }) + 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 @@ -186,7 +201,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 @@ -202,7 +217,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 @@ -516,7 +531,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 ( From f42d07d2dee35ba88595d7aa56d9bbe14566c3a5 Mon Sep 17 00:00:00 2001 From: dajiaohuang Date: Sat, 7 Nov 2026 12:44:53 +0800 Subject: [PATCH 4/5] test: align synthetic decorator path fixture --- tests/test_decorator_errors.py | 3 +++ 1 file changed, 3 insertions(+) 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={}), From 6579930bd9622ac41eaa74152867400f7af8d5fb Mon Sep 17 00:00:00 2001 From: Craig Barratt <19445341+craigbarratt@users.noreply.github.com> Date: Sun, 8 Nov 2026 08:27:30 +1100 Subject: [PATCH 5/5] test: silence pylint protected-access in requirements file test Co-Authored-By: Claude Opus 5.5 --- tests/test_init.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_init.py b/tests/test_init.py index e33dd76..eb17d2d 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -86,6 +86,7 @@ def test_relative_script_path_normalizes_windows_paths(): 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)