Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions custom_components/pyscript/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import shutil
import time
import traceback
from types import ModuleType
from typing import Any

import voluptuous as vol
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions tests/test_decorator_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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={}),
Expand Down
61 changes: 57 additions & 4 deletions tests/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 (
Expand Down
Loading