diff --git a/HABApp/__init__.py b/HABApp/__init__.py index 157a7b46..9e9cab89 100644 --- a/HABApp/__init__.py +++ b/HABApp/__init__.py @@ -18,7 +18,7 @@ import HABApp.util from HABApp.rule import Rule -from HABApp.parameters import Parameter +from HABApp.parameters import Parameter, DictParameter #from HABApp.runtime import Runtime from HABApp.config import CONFIG \ No newline at end of file diff --git a/HABApp/__version__.py b/HABApp/__version__.py index f98e3eef..62a8bdbb 100644 --- a/HABApp/__version__.py +++ b/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '0.16.2' +__version__ = '0.17.0' diff --git a/HABApp/core/__init__.py b/HABApp/core/__init__.py index b23f869d..839d3b0c 100644 --- a/HABApp/core/__init__.py +++ b/HABApp/core/__init__.py @@ -8,6 +8,7 @@ from .event_bus_listener import EventBusListener import HABApp.core.events +import HABApp.core.files import HABApp.core.items import HABApp.core.EventBus diff --git a/HABApp/core/const/__init__.py b/HABApp/core/const/__init__.py index a10374fe..5c1e64c2 100644 --- a/HABApp/core/const/__init__.py +++ b/HABApp/core/const/__init__.py @@ -2,3 +2,4 @@ from . import topics from .const import MISSING from .loop import loop +from .yml import yml \ No newline at end of file diff --git a/HABApp/core/const/topics.py b/HABApp/core/const/topics.py index b945b060..c336de4c 100644 --- a/HABApp/core/const/topics.py +++ b/HABApp/core/const/topics.py @@ -5,15 +5,16 @@ except ImportError: Final = str + +INFOS: Final = 'HABApp.Infos' WARNINGS: Final = 'HABApp.Warnings' ERRORS: Final = 'HABApp.Errors' -INFOS: Final = 'HABApp.Infos' -RULES: Final = 'HABApp.Rules' -PARAM: Final = 'HABApp.Parameters' +FILES: Final = 'HABApp.Files' + ALL: typing.List[str] = [ WARNINGS, ERRORS, INFOS, - RULES, PARAM, + FILES ] diff --git a/HABApp/core/const/yml.py b/HABApp/core/const/yml.py new file mode 100644 index 00000000..21dee11a --- /dev/null +++ b/HABApp/core/const/yml.py @@ -0,0 +1,8 @@ +import ruamel.yaml + +yml = ruamel.yaml.YAML() +yml.default_flow_style = False +yml.default_style = False # type: ignore +yml.width = 1000000 # type: ignore +yml.allow_unicode = True +yml.sort_base_mapping_type_on_output = False # type: ignore diff --git a/HABApp/core/events/habapp_events.py b/HABApp/core/events/habapp_events.py index 9d193bca..bbe062ad 100644 --- a/HABApp/core/events/habapp_events.py +++ b/HABApp/core/events/habapp_events.py @@ -1,45 +1,35 @@ +import HABApp from pathlib import Path -class RequestFileLoadEvent: - """Request (re-) loading of the specified file - - :ivar str filename: relative filename - """ +class __FileEventBase: @classmethod - def from_path(cls, folder: Path, file: Path) -> 'RequestFileLoadEvent': - return cls(str(file.relative_to(folder))) + def from_path(cls, path: Path) -> '__FileEventBase': + return cls(HABApp.core.files.name_from_path(path)) def __init__(self, name: str): - self.filename: str = name + self.name: str = name - def get_path(self, parent_folder: Path) -> Path: - return parent_folder / self.filename + def get_path(self) -> Path: + return HABApp.core.files.path_from_name(self.name) def __repr__(self): - return f'<{self.__class__.__name__} filename: {self.filename}>' + return f'<{self.__class__.__name__} filename: {self.name}>' -class RequestFileUnloadEvent: - """Request unloading of the specified file +class RequestFileLoadEvent(__FileEventBase): + """Request (re-) loading of the specified file :ivar str filename: relative filename """ - @classmethod - def from_path(cls, folder: Path, file: Path) -> 'RequestFileUnloadEvent': - return cls(str(file.relative_to(folder))) - - def __init__(self, name: str): - self.filename: str = name - - def get_path(self, parent_folder: Path) -> Path: - return parent_folder / self.filename +class RequestFileUnloadEvent(__FileEventBase): + """Request unloading of the specified file - def __repr__(self): - return f'<{self.__class__.__name__} filename: {self.filename}>' + :ivar str filename: relative filename + """ class HABAppError: diff --git a/HABApp/core/files/__init__.py b/HABApp/core/files/__init__.py new file mode 100644 index 00000000..6fc9e052 --- /dev/null +++ b/HABApp/core/files/__init__.py @@ -0,0 +1,5 @@ +from . import watcher +from .file_name import path_from_name, name_from_path +from .file import HABAppFile +from .all import watch_folder, file_load_failed, file_load_ok +from .event_listener import add_event_bus_listener diff --git a/HABApp/core/files/all.py b/HABApp/core/files/all.py new file mode 100644 index 00000000..7cd9045f --- /dev/null +++ b/HABApp/core/files/all.py @@ -0,0 +1,107 @@ +import logging +import typing +from itertools import chain +from pathlib import Path +from threading import Lock + +import HABApp +from HABApp.core.wrapper import ignore_exception +from . import name_from_path +from .file import CircularReferenceError, HABAppFile +from .watcher import AggregatingAsyncEventHandler + +log = logging.getLogger('HABApp.files') + +LOCK = Lock() + +ALL: typing.Dict[str, HABAppFile] = {} +LOAD_RUNNING = False + + +def process(files: typing.List[Path], load_next: bool = True): + global LOAD_RUNNING + + for file in files: + name = name_from_path(file) + + # unload + if not file.is_file(): + with LOCK: + existing = ALL.pop(name, None) + if existing is not None: + existing.unload() + continue + + # reload/initial load + obj = HABAppFile.from_path(name, file) + with LOCK: + ALL[name] = obj + + if not load_next: + return None + + # Start loading only once + with LOCK: + if LOAD_RUNNING: + return None + LOAD_RUNNING = True + + _load_next() + + +@ignore_exception +def file_load_ok(name: str): + with LOCK: + file = ALL.get(name) + file.load_ok() + + # reload files with the property "reloads_on" + reload = [k.path for k in filter(lambda f: f.is_loaded and name in f.properties.reloads_on, ALL.values())] + if reload: + process(reload, load_next=False) + + _load_next() + + +@ignore_exception +def file_load_failed(name: str): + with LOCK: + f = ALL.get(name) + f.load_failed() + + _load_next() + + +def _load_next(): + global LOAD_RUNNING + + # check files for dependencies etc. + for file in list(filter(lambda x: not x.is_checked, ALL.values())): + try: + file.check_properties() + except Exception as e: + if not isinstance(e, (CircularReferenceError, FileNotFoundError)): + HABApp.core.wrapper.process_exception(file.check_properties, e, logger=log) + + # Load order is parameters -> openhab config files-> rules + f_n = HABApp.core.files.file_name + _all = sorted(ALL.keys()) + files = chain(filter(f_n.is_param, _all), filter(f_n.is_config, _all), filter(f_n.is_rule, _all)) + + for name in files: + file = ALL[name] + if file.is_loaded or file.is_failed: + continue + + if file.can_be_loaded(): + file.load() + return None + + with LOCK: + LOAD_RUNNING = False + + +def watch_folder(folder: Path, file_ending: str, watch_subfolders: bool = False) -> AggregatingAsyncEventHandler: + handler = AggregatingAsyncEventHandler(folder, process, file_ending, watch_subfolders) + HABApp.core.files.watcher.add_folder_watch(handler) + return handler diff --git a/HABApp/core/files/event_listener.py b/HABApp/core/files/event_listener.py new file mode 100644 index 00000000..549cf5ed --- /dev/null +++ b/HABApp/core/files/event_listener.py @@ -0,0 +1,58 @@ +from typing import Any, Callable, Optional +import logging +from pathlib import Path +from HABApp.core.logger import log_error + +import HABApp + + +def add_event_bus_listener( + file_type: str, + func_load: Optional[Callable[[str, Path], Any]], + func_unload: Optional[Callable[[str, Path], Any]], + logger: logging.Logger): + + func = { + 'config': HABApp.core.files.file_name.is_config, + 'rule': HABApp.core.files.file_name.is_rule, + 'param': HABApp.core.files.file_name.is_param, + }[file_type] + + def filter_func_load(event: HABApp.core.events.habapp_events.RequestFileLoadEvent): + if not func(event.name): + return None + + name = event.name + path = event.get_path() + + # Only load existing files + if not path.is_file(): + log_error(logger, f'{file_type} file "{path}" does not exist and can not be loaded!') + return None + + func_load(name, path) + + def filter_func_unload(event: HABApp.core.events.habapp_events.RequestFileUnloadEvent): + if not func(event.name): + return None + name = event.name + path = event.get_path() + func_unload(name, path) + + if filter_func_unload is not None: + HABApp.core.EventBus.add_listener( + HABApp.core.EventBusListener( + HABApp.core.const.topics.FILES, + HABApp.core.WrappedFunction(filter_func_unload), + HABApp.core.events.habapp_events.RequestFileUnloadEvent + ) + ) + + if filter_func_load is not None: + HABApp.core.EventBus.add_listener( + HABApp.core.EventBusListener( + HABApp.core.const.topics.FILES, + HABApp.core.WrappedFunction(filter_func_load), + HABApp.core.events.habapp_events.RequestFileLoadEvent + ) + ) diff --git a/HABApp/core/files/file.py b/HABApp/core/files/file.py new file mode 100644 index 00000000..5b0627b0 --- /dev/null +++ b/HABApp/core/files/file.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import logging +import typing +from pathlib import Path + +import HABApp +from HABApp.core.const.topics import FILES as T_FILES +from HABApp.core.events.habapp_events import RequestFileLoadEvent, RequestFileUnloadEvent +from .file_props import FileProperties, get_props + +log = logging.getLogger('HABApp.files') + + +class CircularReferenceError(Exception): + pass + + +class HABAppFile: + + @classmethod + def from_path(cls, name: str, path: Path) -> HABAppFile: + with path.open('r', encoding='utf-8') as f: + txt = f.read(10 * 1024) + return cls(name, path, get_props(txt)) + + def __init__(self, name: str, path: Path, properties: FileProperties): + self.name: str = name + self.path: Path = path + self.properties: FileProperties = properties + + # file checks + self.is_checked = False + self.is_valid = False + + # file loaded + self.is_loaded = False + self.is_failed = False + + def _check_refs(self, stack, prop: str): + c: typing.List[str] = getattr(self.properties, prop) + for f in c: + _stack = stack + (f, ) + if f in stack: + log.error(f'Circular reference: {" -> ".join(_stack)}') + raise CircularReferenceError(" -> ".join(_stack)) + + next_file = ALL.get(f) + if next_file is not None: + next_file._check_refs(_stack, prop) + + def check_properties(self): + self.is_checked = True + + # check dependencies + mis = set(filter(lambda x: x not in ALL, self.properties.depends_on)) + if mis: + one = len(mis) == 1 + msg = f'File {self.path} depends on file{"" if one else "s"} that ' \ + f'do{"es" if one else ""}n\'t exist: {", ".join(sorted(mis))}' + log.error(msg) + raise FileNotFoundError(msg) + + # check reload + mis = set(filter(lambda x: x not in ALL, self.properties.reloads_on)) + if mis: + one = len(mis) == 1 + log.warning(f'File {self.path} reloads on file{"" if one else "s"} that ' + f'do{"es" if one else ""}n\'t exist: {", ".join(sorted(mis))}') + + # check for circular references + self._check_refs((self.name, ), 'depends_on') + self._check_refs((self.name, ), 'reloads_on') + + self.is_valid = True + + def can_be_loaded(self) -> bool: + if not self.is_valid: + return False + + for name in self.properties.depends_on: + f = ALL.get(name, None) + if f is None: + return False + + if not f.is_loaded: + return False + return True + + def load(self): + self.is_loaded = False + + HABApp.core.EventBus.post_event( + T_FILES, RequestFileLoadEvent(self.name) + ) + + def unload(self): + HABApp.core.EventBus.post_event( + T_FILES, RequestFileUnloadEvent(self.name) + ) + + def load_ok(self): + self.is_loaded = True + + def load_failed(self): + self.is_failed = True + + +from .all import ALL # noqa F401 diff --git a/HABApp/core/files/file_name.py b/HABApp/core/files/file_name.py new file mode 100644 index 00000000..d28b9228 --- /dev/null +++ b/HABApp/core/files/file_name.py @@ -0,0 +1,42 @@ +import HABApp +from pathlib import Path + + +PREFIX_CONFIGS = 'configs' +PREFIX_PARAMS = 'params' +PREFIX_RULES = 'rules' + + +def name_from_path(path: Path) -> str: + _path = path.as_posix() + d = HABApp.config.CONFIG.directories + folders = {PREFIX_CONFIGS: d.config.as_posix(), PREFIX_PARAMS: d.param.as_posix(), PREFIX_RULES: d.rules.as_posix()} + + for prefix, folder in folders.items(): + if _path.startswith(folder): + return prefix + '/' + _path[len(folder) + 1:] + + raise ValueError(f'Path "{path}" is not part of the configured folders!') + + +def path_from_name(name: str) -> Path: + d = HABApp.config.CONFIG.directories + folders = {PREFIX_CONFIGS: d.config.as_posix(), PREFIX_PARAMS: d.param.as_posix(), PREFIX_RULES: d.rules.as_posix()} + + for prefix, folder in folders.items(): + if name.startswith(prefix): + return Path(folder + '/' + name[len(prefix):]) + + raise ValueError(f'Prefix not found for "{name}"!') + + +def is_config(name: str): + return name.startswith(PREFIX_CONFIGS) + + +def is_param(name: str): + return name.startswith(PREFIX_PARAMS) + + +def is_rule(name: str): + return name.startswith(PREFIX_RULES) diff --git a/HABApp/core/files/file_props.py b/HABApp/core/files/file_props.py new file mode 100644 index 00000000..e80a7751 --- /dev/null +++ b/HABApp/core/files/file_props.py @@ -0,0 +1,62 @@ +import re +from typing import List + +from pydantic import BaseModel, Extra, Field + +from HABApp.core.const import yml + + +class FileProperties(BaseModel): + depends_on: List[str] = Field(alias='depends on', default_factory=list) + reloads_on: List[str] = Field(alias='reloads on', default_factory=list) + + class Config: + extra = Extra.forbid + allow_population_by_field_name = True + + +RE_START = re.compile(r'^#(\s*)HABApp\s*:', re.IGNORECASE) + + +def get_props(_str: str) -> FileProperties: + + cfg = [] + cut = 0 + + # extract the property string + for line in _str.splitlines(): + line = line.strip() + if cut and not line: + break + + if not line: + continue + + # break on first non empty line that is not a comment + if line and not line.startswith('#'): + break + + if not cut: + # find out how much from the start we have to cut + m = RE_START.search(line) + if m: + cut = len(m.group(1)) + 1 + cfg.append(line[cut:].lower()) + else: + do_break = False + for i, c in enumerate(line): + if i > cut: + break + + if c not in ('#', ' ', '\t'): + do_break = True + break + if do_break: + break + + cfg.append(line[cut:]) + + data = yml.load('\n'.join(cfg)) + if data is None: + data = {} + return FileProperties.parse_obj(data.get('habapp', {})) diff --git a/HABApp/core/files/watcher/__init__.py b/HABApp/core/files/watcher/__init__.py new file mode 100644 index 00000000..7b620c09 --- /dev/null +++ b/HABApp/core/files/watcher/__init__.py @@ -0,0 +1,2 @@ +from .folder_watcher import start, remove_folder_watch, add_folder_watch +from .file_watcher import AggregatingAsyncEventHandler diff --git a/HABApp/core/files/watcher/base_watcher.py b/HABApp/core/files/watcher/base_watcher.py new file mode 100644 index 00000000..fd6fd90e --- /dev/null +++ b/HABApp/core/files/watcher/base_watcher.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from watchdog.events import FileSystemEvent, FileSystemEventHandler + + +class BaseWatcher(FileSystemEventHandler): + def __init__(self, folder: Path, file_ending: str, watch_subfolders: bool = False): + assert isinstance(folder, Path), type(folder) + assert isinstance(file_ending, str), type(file_ending) + assert watch_subfolders is True or watch_subfolders is False + + self.folder: Path = folder + self.file_ending: str = file_ending + self.watch_subfolders: bool = watch_subfolders + + def dispatch(self, event: FileSystemEvent): + # we don't process directory events + if event.is_directory: + return None + + src = event.src_path + if src.endswith(self.file_ending): + self.file_changed(src) + + # moved events have a dst, so we process it, too + if hasattr(event, 'dest_path'): + dst = event.dest_path + if dst.endswith(self.file_ending): + self.file_changed(dst) + return None + + def file_changed(self, dst: str): + raise NotImplementedError() diff --git a/HABApp/core/files/watcher/file_watcher.py b/HABApp/core/files/watcher/file_watcher.py new file mode 100644 index 00000000..ae3420a2 --- /dev/null +++ b/HABApp/core/files/watcher/file_watcher.py @@ -0,0 +1,55 @@ +import asyncio +import typing +from pathlib import Path +from threading import Lock + +import HABApp +from HABApp.core.wrapper import ignore_exception +from .base_watcher import BaseWatcher as __BaseWatcher + +LOCK = Lock() + + +class AggregatingAsyncEventHandler(__BaseWatcher): + def __init__(self, folder: Path, func: typing.Callable[[typing.List[Path]], typing.Any], file_ending: str, + watch_subfolders: bool = False): + super().__init__(folder, file_ending, watch_subfolders=watch_subfolders) + + self.func = func + + self.__task: typing.Optional[asyncio.Future] = None + self.__files: typing.Set[str] = set() + + def __execute(self): + with LOCK: + self.func([Path(f) for f in self.__files]) + self.__files.clear() + + @ignore_exception + def file_changed(self, dst: str): + # this has to be thread safe! + with LOCK: + self.__files.add(dst) + + # cancel already running Task + if self.__task is not None: + self.__task.cancel() + + # and create a new one + self.__task = asyncio.run_coroutine_threadsafe(self.__event_waiter(), loop=HABApp.core.const.loop) + + @ignore_exception + async def __event_waiter(self): + try: + # debounce time + await asyncio.sleep(0.6) + + # trigger file event + self.__task = None + self.__execute() + except asyncio.CancelledError: + pass + + def trigger_all(self): + files = HABApp.core.lib.list_files(self.folder, self.file_ending, self.watch_subfolders) + self.func(files) diff --git a/HABApp/core/files/watcher/folder_watcher.py b/HABApp/core/files/watcher/folder_watcher.py new file mode 100644 index 00000000..20680871 --- /dev/null +++ b/HABApp/core/files/watcher/folder_watcher.py @@ -0,0 +1,51 @@ +from pathlib import Path +from threading import Lock +from typing import Optional, Dict + +from watchdog.observers import Observer +from watchdog.observers.api import ObservedWatch + +from .base_watcher import BaseWatcher + +LOCK = Lock() + +OBSERVER: Optional[Observer] = None +WATCHES: Dict[str, ObservedWatch] = {} + + +def start(shutdown_helper): + global OBSERVER + + # start only once! + assert OBSERVER is None + + from HABApp.runtime.shutdown_helper import ShutdownHelper + assert isinstance(shutdown_helper, ShutdownHelper) + + OBSERVER = Observer() + OBSERVER.start() + + # register for proper shutdown + shutdown_helper.register_func(OBSERVER.stop) + shutdown_helper.register_func(OBSERVER.join, last=True) + return None + + +def add_folder_watch(handler: BaseWatcher): + assert OBSERVER is not None + assert isinstance(handler, BaseWatcher), type(handler) + assert isinstance(handler.folder, Path) and handler.folder.is_dir() + + with LOCK: + _folder = str(handler.folder) + assert _folder not in WATCHES + + WATCHES[_folder] = OBSERVER.schedule(handler, _folder, recursive=handler.watch_subfolders) + + +def remove_folder_watch(folder: Path): + assert OBSERVER is not None + assert isinstance(folder, Path) + + with LOCK: + OBSERVER.unschedule(WATCHES.pop(str(folder))) diff --git a/HABApp/openhab/connection_handler/http_connection.py b/HABApp/openhab/connection_handler/http_connection.py index c578c48a..d08c79cb 100644 --- a/HABApp/openhab/connection_handler/http_connection.py +++ b/HABApp/openhab/connection_handler/http_connection.py @@ -138,7 +138,7 @@ def set_offline(log_msg=''): def is_disconnect_exception(e) -> bool: if not isinstance(e, ( # aiohttp Exceptions - aiohttp.ClientPayloadError, aiohttp.ClientConnectorError, + aiohttp.ClientPayloadError, aiohttp.ClientConnectorError, aiohttp.ClientOSError, # aiohttp_sse_client Exceptions ConnectionRefusedError, ConnectionError, ConnectionAbortedError)): @@ -318,10 +318,12 @@ async def try_uuid(): except Exception as e: if isinstance(e, (OpenhabDisconnectedError, OpenhabNotReadyYet)): log.info('... offline!') - FUT_UUID = asyncio.ensure_future(try_uuid()) else: for line in traceback.format_exc().splitlines(): log.error(line) + + # Keep trying to connect + FUT_UUID = asyncio.ensure_future(try_uuid()) return None if IS_READ_ONLY: diff --git a/HABApp/openhab/connection_logic/plugin_ping.py b/HABApp/openhab/connection_logic/plugin_ping.py index 7542409f..45b82a95 100644 --- a/HABApp/openhab/connection_logic/plugin_ping.py +++ b/HABApp/openhab/connection_logic/plugin_ping.py @@ -13,8 +13,8 @@ class PingOpenhab(PluginBase): def __init__(self): - self.__ping_sent = 0 - self.__ping_received = 0 + self.__ping_received: float = 0 + self.__ping_value: Optional[float] = 0 self.listener: Optional[HABApp.core.EventBusListener] = None @@ -42,6 +42,8 @@ def on_disconnect(self): self.fut_ping.cancel() self.fut_ping = None + self.__ping_received = 0 + self.__ping_value = 0 log.debug('Ping stopped') def cfg_changed(self): @@ -59,21 +61,32 @@ def cfg_changed(self): self.on_connect() - async def ping_received(self, event): + async def ping_received(self, event: HABApp.openhab.events.ItemStateEvent): + value = event.value self.__ping_received = time.time() + self.__ping_value = None if value is None else round(value, 2) @log_exception async def async_ping(self): await asyncio.sleep(3) + sent: Optional[float] = time.time() + value: Optional[float] = None + log.debug('Ping started') try: while True: + if self.__ping_value == value: + value = round((self.__ping_received - sent) * 1000, 2) + else: + value = None + await HABApp.openhab.interface_async.async_post_update( HABApp.config.CONFIG.openhab.ping.item, - f'{(self.__ping_received - self.__ping_sent) * 1000:.1f}' if self.__ping_received else '0' + f'{value:.1f}' if value is not None else None ) - self.__ping_sent = time.time() + sent = time.time() + await asyncio.sleep(HABApp.config.CONFIG.openhab.ping.interval) except (OpenhabNotReadyYet, OpenhabDisconnectedError): diff --git a/HABApp/openhab/connection_logic/plugin_things/plugin_things.py b/HABApp/openhab/connection_logic/plugin_things/plugin_things.py index 8b160c54..e58b08c0 100644 --- a/HABApp/openhab/connection_logic/plugin_things/plugin_things.py +++ b/HABApp/openhab/connection_logic/plugin_things/plugin_things.py @@ -1,6 +1,6 @@ import asyncio from pathlib import Path -from typing import Dict, Set +from typing import Dict, Set, List import HABApp from HABApp.core.lib import PendingFuture @@ -23,6 +23,23 @@ def __init__(self): self.created_items: Dict[str, Set[str]] = {} self.do_cleanup = PendingFuture(self.clean_items, 120) + def setup(self): + # Add event bus listener + HABApp.core.EventBus.add_listener( + HABApp.core.EventBusListener( + HABApp.core.const.topics.FILES, + HABApp.core.WrappedFunction(self.file_load_event), + HABApp.core.events.habapp_events.RequestFileLoadEvent + ) + ) + + # watch folder + HABApp.core.files.watch_folder(HABApp.CONFIG.directories.config, '.yml', True) + + async def file_load_event(self, event: HABApp.core.events.habapp_events.RequestFileLoadEvent): + if HABApp.core.files.file_name.is_config(event.name): + await self.update_thing_config(event.get_path()) + async def on_connect_function(self): try: await asyncio.sleep(0.3) @@ -49,6 +66,14 @@ async def clean_items(self): items.update(s) await cleanup_items(items) + async def update_thing_configs(self, files: List[Path]): + data = await async_get_things() + if data is None: + return None + + for file in files: + await self.update_thing_config(file, data) + @HABApp.core.wrapper.ignore_exception async def update_thing_config(self, path: Path, data=None): # we have to check the naming structure because we get file events for the whole folder @@ -79,10 +104,9 @@ async def update_thing_config(self, path: Path, data=None): log.debug(f'Loading {path}!') # load the config file - yml = HABApp.parameters.parameter_files._yml_setup with path.open(mode='r', encoding='utf-8') as file: try: - cfg = yml.load(file) + cfg = HABApp.core.const.yml.load(file) except Exception as e: HABAppError(log).add_exception(e).dump() return None diff --git a/HABApp/parameters/__init__.py b/HABApp/parameters/__init__.py index 3393e751..a69c6a82 100644 --- a/HABApp/parameters/__init__.py +++ b/HABApp/parameters/__init__.py @@ -1,3 +1,3 @@ -from .parameter import Parameter +from .parameter import Parameter, DictParameter from .parameters import set_file_validator diff --git a/HABApp/parameters/parameter.py b/HABApp/parameters/parameter.py index 53ecebd5..91c762a5 100644 --- a/HABApp/parameters/parameter.py +++ b/HABApp/parameters/parameter.py @@ -5,7 +5,7 @@ from .parameters import get_value as _get_value -class Parameter: +class BaseParameter: def __init__(self, filename: str, *keys, default_value: typing.Any = 'ToDo'): """Class to dynamically access parameters which are loaded from file. @@ -16,22 +16,25 @@ def __init__(self, filename: str, *keys, default_value: typing.Any = 'ToDo'): """ assert isinstance(filename, str), type(filename) - self.filename: str = filename - self.keys = keys + self._filename: str = filename + self._keys = keys # as a convenience try to create the file and the file structure if default_value is not None: - _add_parameter(self.filename, *self.keys, default_value=default_value) + _add_parameter(self._filename, *self._keys, default_value=default_value) + + +class Parameter(BaseParameter): @property def value(self) -> typing.Any: """Return the current value. This will do the lookup so make sure to not cache this value, otherwise the parameter might not work as expected. """ - return _get_value(self.filename, *self.keys) + return _get_value(self._filename, *self._keys) def __repr__(self): - return f' dict: + """Return the current value. This will do the lookup so make sure to not cache this value, otherwise + the parameter might not work as expected. + """ + value = _get_value(self._filename, *self._keys) + if not isinstance(value, dict): + raise ValueError(f'Value "{value}" for {self.__class__.__name__} is not a dict! ({type(value)})') + return value + + def __repr__(self): + return f' bool: - if not HABApp.CONFIG.directories.param.is_dir(): log.info(f'Parameter files disabled: Folder {HABApp.CONFIG.directories.param} does not exist!') return False - # listener to remove parameters - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener( - HABApp.core.const.topics.PARAM, - HABApp.core.WrappedFunction(unload_file), - HABApp.core.events.habapp_events.RequestFileUnloadEvent - ) - ) - # listener to add parameters - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener( - HABApp.core.const.topics.PARAM, - HABApp.core.WrappedFunction(load_file), - HABApp.core.events.habapp_events.RequestFileLoadEvent - ) - ) - return True + # Add event bus listener + HABApp.core.files.add_event_bus_listener('param', load_file, unload_file, log) + # watch folder and load all files + watcher = HABApp.core.files.watch_folder(HABApp.CONFIG.directories.param, '.yml', True) + watcher.trigger_all() + return True -def load_file(event: HABApp.core.events.habapp_events.RequestFileLoadEvent): - path = event.get_path(HABApp.CONFIG.directories.param) +def load_file(name: str, path: Path): with LOCK: # serialize to get proper error messages try: with path.open(mode='r', encoding='utf-8') as file: - data = _yml_setup.load(file) + data = HABApp.core.const.yml.load(file) if data is None: data = {} set_parameter_file(path.stem, data) except Exception as exc: e = HABApp.core.logger.HABAppError(log) - e.add(f"Could not load params from {path.name}!") + e.add(f"Could not load parameters for {name} ({path})!") e.add_exception(exc, add_traceback=True) e.dump() - return None - log.debug(f'Loaded params from {path.name}!') + file_load_failed(name) + return None + log.debug(f'Loaded params from {path.name}!') + file_load_ok(name) -def unload_file(event: HABApp.core.events.habapp_events.RequestFileUnloadEvent): - path = event.get_path(HABApp.CONFIG.directories.param) +def unload_file(name: str, path: Path): with LOCK: # serialize to get proper error messages try: remove_parameter_file(path.stem) except Exception as exc: e = HABApp.core.logger.HABAppError(log) - e.add(f"Could not remove parameters from {path.name}!") + e.add(f"Could not remove parameters for {name} ({path})!") e.add_exception(exc, add_traceback=True) e.dump() return None @@ -86,4 +67,4 @@ def save_file(file: str): with LOCK: # serialize to get proper error messages log.info(f'Updated {filename}') with filename.open('w', encoding='utf-8') as outfile: - _yml_setup.dump(get_parameter_file(file), outfile) + HABApp.core.const.yml.dump(get_parameter_file(file), outfile) diff --git a/HABApp/parameters/parameters.py b/HABApp/parameters/parameters.py index 865918f3..3d535dab 100644 --- a/HABApp/parameters/parameters.py +++ b/HABApp/parameters/parameters.py @@ -48,10 +48,9 @@ def set_file_validator(filename: str, validator: typing.Any, allow_extra_keys=Tr # todo: move this to file handling so we get the extension if old_validator != new_validator: - HABApp.core.EventBus.post_event( - HABApp.core.const.topics.PARAM, - HABApp.core.events.habapp_events.RequestFileLoadEvent(filename + '.yml') - ) + name = HABApp.core.files.file_name.PREFIX_PARAMS + '/' + filename + '.yml' + path = HABApp.core.files.file_name.path_from_name(name) + HABApp.core.files.all.process([path]) def add_parameter(file: str, *keys, default_value): diff --git a/HABApp/rule_manager/rule_manager.py b/HABApp/rule_manager/rule_manager.py index e5215027..aa14928f 100644 --- a/HABApp/rule_manager/rule_manager.py +++ b/HABApp/rule_manager/rule_manager.py @@ -4,19 +4,33 @@ import math import threading import time -import traceback import typing from pytz import utc import HABApp +from pathlib import Path +from HABApp.core.files import file_load_failed, file_load_ok +from HABApp.core.files.watcher import AggregatingAsyncEventHandler +from HABApp.core.logger import log_warning from HABApp.core.wrapper import log_exception from .rule_file import RuleFile -from HABApp.core.const.topics import RULES as TOPIC_RULES log = logging.getLogger('HABApp.Rules') +LOAD_DELAY = 1 + + +async def set_load_ok(name: str): + await asyncio.sleep(LOAD_DELAY) + file_load_ok(name) + + +async def set_load_failed(name: str): + await asyncio.sleep(LOAD_DELAY) + file_load_failed(name) + class RuleManager: @@ -33,24 +47,15 @@ def __init__(self, parent): # Processing self.__process_last_sec = 60 + self.watcher: typing.Optional[AggregatingAsyncEventHandler] = None - # Listener to add rules - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener( - TOPIC_RULES, - HABApp.core.WrappedFunction(self.request_file_load), - HABApp.core.events.habapp_events.RequestFileLoadEvent - ) - ) - - # listener to remove rules - HABApp.core.EventBus.add_listener( - HABApp.core.EventBusListener( - TOPIC_RULES, - HABApp.core.WrappedFunction(self.request_file_unload), - HABApp.core.events.habapp_events.RequestFileUnloadEvent - ) - ) + def setup(self): + + # Add event bus listener + HABApp.core.files.add_event_bus_listener('rule', self.request_file_load, self.request_file_unload, log) + + # Folder watcher + self.watcher = HABApp.core.files.watch_folder(HABApp.CONFIG.directories.rules, '.py', True) # Initial loading of rules HABApp.core.WrappedFunction(self.load_rules_on_startup, logger=log, warn_too_long=False).run() @@ -74,8 +79,7 @@ def load_rules_on_startup(self): time.sleep(5.2) # trigger event for every file - w = self.runtime.folder_watcher.get_handler(HABApp.CONFIG.directories.rules) - w.trigger_load_for_all_files(delay=1) + self.watcher.trigger_all() return None @log_exception @@ -133,8 +137,7 @@ def get_rule(self, rule_name): @log_exception - def request_file_unload(self, event: HABApp.core.events.habapp_events.RequestFileUnloadEvent, request_lock=True): - path = event.get_path(HABApp.CONFIG.directories.rules) + def request_file_unload(self, name: str, path: Path, request_lock=True): path_str = str(path) try: @@ -145,31 +148,30 @@ def request_file_unload(self, event: HABApp.core.events.habapp_events.RequestFil with self.__files_lock: already_loaded = path_str in self.files if not already_loaded: - log.warning(f'Rule file {path} is not yet loaded and therefore can not be unloaded') + log_warning(log, f'Rule file {path} is not yet loaded and therefore can not be unloaded') return None log.debug(f'Removing file: {path}') with self.__files_lock: rule = self.files.pop(path_str) rule.unload() - except Exception: - log.error(f"Could not remove {path}!") - for line in traceback.format_exc().splitlines(): - log.error(line) + except Exception as e: + err = HABApp.core.logger.HABAppError(log) + err.add(f"Could not remove {path}!") + err.add_exception(e, True) + err.dump() return None finally: if request_lock: self.__load_lock.release() @log_exception - def request_file_load(self, event: HABApp.core.events.habapp_events.RequestFileLoadEvent): - - path = event.get_path(HABApp.CONFIG.directories.rules) + def request_file_load(self, name: str, path: Path): path_str = str(path) # Only load existing files if not path.is_file(): - log.warning(f'Rule file {path} does not exist and can not be loaded!') + log_warning(log, f'Rule file {path} does not exist and can not be loaded!') return None with self.__load_lock: @@ -177,7 +179,7 @@ def request_file_load(self, event: HABApp.core.events.habapp_events.RequestFileL with self.__files_lock: already_loaded = path_str in self.files if already_loaded: - self.request_file_unload(event, request_lock=False) + self.request_file_unload(name, path, request_lock=False) log.debug(f'Loading file: {path}') with self.__files_lock: @@ -188,9 +190,15 @@ def request_file_load(self, event: HABApp.core.events.habapp_events.RequestFileL # Unloading is handled directly in the load function self.files.pop(path_str) log.warning(f'Failed to load {path_str}!') + + # signal that we have loaded the file but with a small delay + asyncio.run_coroutine_threadsafe(set_load_failed(name), HABApp.core.const.loop) return None log.debug(f'File {path_str} successfully loaded!') + # signal that we have loaded the file but with a small delay + asyncio.run_coroutine_threadsafe(set_load_ok(name), HABApp.core.const.loop) + # Do simple checks which prevent errors file.check_all_rules() diff --git a/HABApp/runtime/__init__.py b/HABApp/runtime/__init__.py index 529a5895..7c602c51 100644 --- a/HABApp/runtime/__init__.py +++ b/HABApp/runtime/__init__.py @@ -1,3 +1,2 @@ from .shutdown_helper import ShutdownHelper -from .folder_watcher import SimpleAsyncEventHandler, FolderWatcher from .runtime import Runtime \ No newline at end of file diff --git a/HABApp/runtime/folder_watcher/__init__.py b/HABApp/runtime/folder_watcher/__init__.py deleted file mode 100644 index 404e9cdb..00000000 --- a/HABApp/runtime/folder_watcher/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .simpleasyncfileevent import SimpleAsyncEventHandler -from .folder_watcher import FolderWatcher \ No newline at end of file diff --git a/HABApp/runtime/folder_watcher/folder_watcher.py b/HABApp/runtime/folder_watcher/folder_watcher.py deleted file mode 100644 index fd0b2762..00000000 --- a/HABApp/runtime/folder_watcher/folder_watcher.py +++ /dev/null @@ -1,75 +0,0 @@ -import pathlib -import typing - -from watchdog.observers import Observer - -from .habappfileevent import FileEventToHABAppEvent -from .simpleasyncfileevent import SimpleAsyncEventHandler - - -class FolderWatcher: - def __init__(self): - - self.__observer = Observer() - self.__handlers: typing.Dict[str, typing.Union[SimpleAsyncEventHandler, FileEventToHABAppEvent]] = {} - self.__watches = {} - self.__started = False - - def start(self, shutdown_helper): - from ..shutdown_helper import ShutdownHelper - assert isinstance(shutdown_helper, ShutdownHelper) - - # we shall only start once! - assert self.__started is False - self.__started = True - - # start watching the folders - self.__observer.start() - - # register for proper shutdown - shutdown_helper.register_func(self.__observer.stop) - shutdown_helper.register_func(self.__observer.join, last=True) - return None - - def watch_folder(self, folder: pathlib.Path, file_ending: str, target_func, - watch_subfolders = False, worker_factory=None) -> SimpleAsyncEventHandler: - assert isinstance(folder, pathlib.Path), type(folder) - assert folder.is_dir(), folder - - folder_str = str(folder) - assert folder_str not in self.__watches, folder_str - - self.__handlers[folder_str] = handler = SimpleAsyncEventHandler( - target_func=target_func, file_ending=file_ending, worker_factory=worker_factory - ) - self.__watches[folder_str] = self.__observer.schedule(handler, path=folder_str, recursive=watch_subfolders) - return handler - - def watch_folder_habapp_events(self, folder: pathlib.Path, file_ending: str, habapp_topic: str, - watch_subfolders: bool = False): - assert isinstance(folder, pathlib.Path), type(folder) - assert folder.is_dir(), folder - - folder_str = str(folder) - assert folder_str not in self.__watches, folder_str - - self.__handlers[folder_str] = handler = FileEventToHABAppEvent( - folder=folder, habapp_topic=habapp_topic, file_ending=file_ending, recursive=watch_subfolders - ) - self.__watches[folder_str] = self.__observer.schedule(handler, path=folder_str, recursive=watch_subfolders) - return handler - - def unwatch_folder(self, folder): - if isinstance(folder, pathlib.Path): - folder = str(folder) - assert isinstance(folder, str), type(folder) - - self.__handlers.pop(folder) - self.__observer.unschedule(self.__watches.pop(folder)) - - def get_handler(self, folder) -> typing.Union[SimpleAsyncEventHandler, FileEventToHABAppEvent]: - if isinstance(folder, pathlib.Path): - folder = str(folder) - assert isinstance(folder, str), type(folder) - - return self.__handlers[folder] diff --git a/HABApp/runtime/folder_watcher/habappfileevent.py b/HABApp/runtime/folder_watcher/habappfileevent.py deleted file mode 100644 index eba0f80b..00000000 --- a/HABApp/runtime/folder_watcher/habappfileevent.py +++ /dev/null @@ -1,44 +0,0 @@ -import time -from pathlib import Path - -import HABApp -from .simpleasyncfileevent import SimpleAsyncEventHandler - - -class FileEventToHABAppEvent(SimpleAsyncEventHandler): - def __init__(self, folder: Path, habapp_topic: str, file_ending: str, recursive=False): - assert isinstance(folder, Path), type(folder) - assert isinstance(file_ending, str), type(file_ending) - assert isinstance(habapp_topic, str), type(habapp_topic) - assert isinstance(recursive, bool), type(recursive) - - super().__init__(self.create_habapp_event, file_ending) - - self.folder: Path = folder - self.habapp_topic: str = habapp_topic - self.recursive: bool = recursive - - def create_habapp_event(self, path: Path): - if path.is_file(): - event = HABApp.core.events.habapp_events.RequestFileLoadEvent - else: - event = HABApp.core.events.habapp_events.RequestFileUnloadEvent - - HABApp.core.EventBus.post_event( - self.habapp_topic, event.from_path(self.folder, path) - ) - - def trigger_load_for_all_files(self, delay: int = None): - - for f in HABApp.core.lib.list_files(self.folder, self.file_ending, self.recursive): - if not f.name.endswith(self.file_ending): - continue - - if delay is not None: - time.sleep(delay) - - HABApp.core.EventBus.post_event( - self.habapp_topic, HABApp.core.events.habapp_events.RequestFileLoadEvent.from_path( - folder=self.folder, file=f - ) - ) diff --git a/HABApp/runtime/folder_watcher/simpleasyncfileevent.py b/HABApp/runtime/folder_watcher/simpleasyncfileevent.py deleted file mode 100644 index abe93361..00000000 --- a/HABApp/runtime/folder_watcher/simpleasyncfileevent.py +++ /dev/null @@ -1,77 +0,0 @@ -import asyncio -import typing -from pathlib import Path -from threading import Lock - -from watchdog.events import FileSystemEvent, FileSystemEventHandler - -import HABApp -from HABApp.core.wrapper import ignore_exception - -LOCK = Lock() - - -class SimpleAsyncEventHandler(FileSystemEventHandler): - def __init__(self, target_func: typing.Callable[[Path], typing.Any], file_ending: str, worker_factory=None): - self.__target_func = target_func - - assert isinstance(file_ending, str), type(file_ending) - self.file_ending = file_ending - - # Possibility to use a wrapper to load files - # do not reuse an instantiated WrappedFunction because it will throw errors in the traceback module - self.__worker_factory = worker_factory - - # Pending events - self.__tasks: typing.Dict[str, asyncio.Future] = {} - - def __execute(self, dst: str): - if self.__worker_factory is None: - return self.__target_func(Path(dst)) - return self.__worker_factory(self.__target_func)(Path(dst)) - - def dispatch(self, event): - self.on_any_event(event) - - def on_any_event(self, event: FileSystemEvent): - # we don't process directory events - if event.is_directory: - return None - - src = event.src_path - if src.endswith(self.file_ending): - self.process_dst(src) - - # moved events have a dst, so we process it, too - if hasattr(event, 'dest_path'): - dst = event.dest_path - if dst.endswith(self.file_ending): - self.process_dst(dst) - return None - - @ignore_exception - def process_dst(self, dst: str): - # this has to be thread safe! - with LOCK: - try: - # cancel already running Task - self.__tasks[dst].cancel() - except KeyError: - pass - # and create a new one - self.__tasks[dst] = asyncio.run_coroutine_threadsafe(self.event_waiter(dst), loop=HABApp.core.const.loop) - - @ignore_exception - async def event_waiter(self, dst: str): - try: - # debounce time - await asyncio.sleep(0.4) - - # remove debounce task for target file - with LOCK: - _ = self.__tasks.pop(dst, None) - - # trigger file event - self.__execute(dst) - except asyncio.CancelledError: - pass diff --git a/HABApp/runtime/runtime.py b/HABApp/runtime/runtime.py index 19a6bff0..4b86d44b 100644 --- a/HABApp/runtime/runtime.py +++ b/HABApp/runtime/runtime.py @@ -3,14 +3,11 @@ import HABApp.config import HABApp.core +import HABApp.parameters.parameter_files import HABApp.rule_manager import HABApp.util -import HABApp.parameters.parameter_files - -from .folder_watcher import FolderWatcher -from .shutdown_helper import ShutdownHelper -from HABApp.config import CONFIG from HABApp.openhab import connection_logic as openhab_connection +from .shutdown_helper import ShutdownHelper class Runtime: @@ -18,8 +15,6 @@ class Runtime: def __init__(self): self.shutdown = ShutdownHelper() - self.folder_watcher: FolderWatcher = FolderWatcher() - self.config: HABApp.config.Config = None self.async_http: HABApp.rule.interfaces.AsyncHttpConnection = HABApp.rule.interfaces.AsyncHttpConnection() @@ -40,7 +35,7 @@ def __init__(self): def startup(self, config_folder: Path): # Start Folder watcher! - self.folder_watcher.start(self.shutdown) + HABApp.core.files.watcher.start(self.shutdown) self.config_loader = HABApp.config.HABAppConfigLoader(config_folder) @@ -52,42 +47,12 @@ def startup(self, config_folder: Path): openhab_connection.setup(self.shutdown), # Parameter Files - params_enabled = HABApp.parameters.parameter_files.setup_param_files() + HABApp.parameters.parameter_files.setup_param_files() # Rule engine self.rule_manager = HABApp.rule_manager.RuleManager(self) + self.rule_manager.setup() - # watch folders config - self.folder_watcher.watch_folder( - folder=config_folder, - file_ending='.yml', - target_func=self.config_loader.on_file_event - ) - - # folder watcher rules - self.folder_watcher.watch_folder_habapp_events( - folder=CONFIG.directories.rules, file_ending='.py', - habapp_topic=HABApp.core.const.topics.RULES, watch_subfolders=True - ) - - # watch parameter files - if params_enabled: - param_watcher = self.folder_watcher.watch_folder_habapp_events( - folder=HABApp.CONFIG.directories.param, file_ending='.yml', - habapp_topic=HABApp.core.const.topics.PARAM, watch_subfolders=True - ) - # load all param files through the worker - HABApp.core.WrappedFunction(param_watcher.trigger_load_for_all_files, name='Load all parameter files').run() - - # watch folders for manual config - if CONFIG.directories.config.is_dir(): - self.folder_watcher.watch_folder( - folder=CONFIG.directories.config, - file_ending='.yml', - target_func=lambda x: asyncio.run_coroutine_threadsafe( - openhab_connection.PLUGIN_MANUAL_THING_CFG.update_thing_config(x), HABApp.core.const.loop - ) - ) @HABApp.core.wrapper.log_exception def get_async(self): diff --git a/_doc/advanced_usage.rst b/_doc/advanced_usage.rst index f99684b7..8c57b029 100644 --- a/_doc/advanced_usage.rst +++ b/_doc/advanced_usage.rst @@ -5,9 +5,7 @@ Advanced Usage HABApp Topics ------------------------------ There are several internal topics which can be used to react to HABApp changes from within rules. -An example would be dynamically reloading rules when a parameter file gets reloaded -(e.g. when :class:`~HABApp.parameters.Parameter` is used to create rules dynamically) or -an own notifier in case there are errors (e.g. Pushover). +An example would be dynamically reloading files or an own notifier in case there are errors (e.g. Pushover). .. list-table:: :widths: auto @@ -17,13 +15,17 @@ an own notifier in case there are errors (e.g. Pushover). - Description - Events - * - HABApp.Rules - - The corresponding events trigger a load/unload of the rule file specified in the event + * - HABApp.Files + - The corresponding events trigger a load/unload of the file specified in the event - :class:`~HABApp.core.events.habapp_events.RequestFileLoadEvent` and :class:`~HABApp.core.events.habapp_events.RequestFileUnloadEvent` - * - HABApp.Parameters - - The corresponding events trigger a load/unload of the parameter file specified in the event - - :class:`~HABApp.core.events.habapp_events.RequestFileLoadEvent` and :class:`~HABApp.core.events.habapp_events.RequestFileUnloadEvent` + * - HABApp.Infos + - All infos in functions and rules of HABApp create an according event + - ``str`` + + * - HABApp.Warnings + - All warnings in functions and rules of HABApp create an according event + - ``str`` * - HABApp.Errors - All errors in functions and rules of HABApp create an according event. Use this topic to create an own notifier @@ -41,6 +43,54 @@ an own notifier in case there are errors (e.g. Pushover). .. autoclass:: HABApp.core.events.habapp_events.HABAppError :members: +File properties +------------------------------ +For every HABApp file it is possible to specify some properties. +The properties are specified as a comment (prefixed with ``#``) somewhere at the beginning of the file +and are in the yml format. +File names are the same as in the :class:`~HABApp.core.events.habapp_events.RequestFileLoadEvent`. + +Configuration format + +.. code-block:: yaml + + HABApp: + depends on: + - filename + reloads on: + - filename + +.. list-table:: + :widths: auto + :header-rows: 1 + + * - Property + - Description + + * - ``depends on`` + - The file will only get loaded when **all** of the files specified as dependencies have been successfully loaded + + * - ``reloads on`` + - The file will get automatically reloaded when **one of** the files specified will be reloaded + + +Example + +.. code-block:: python + + # Some other stuff + # + # HABApp: + # depends on: + # - rules/rule_file.py + # reloads on: + # - params/param_file.yml + + import HABApp + ... + + + AggregationItem ------------------------------ The aggregation item is an item which takes the values of another item as an input. @@ -58,8 +108,8 @@ And since it is just like a normal item triggering on changes etc. is possible, # Connect the source item with the aggregation item my_agg.aggregation_source('MyInputItem') - # Aggregate all changes in the last hour - my_agg.aggregation_period(3600) + # Aggregate all changes in the last two hours + my_agg.aggregation_period(2 * 3600) # Use max as an aggregation function my_agg.aggregation_func = max diff --git a/_doc/installation.rst b/_doc/installation.rst index dc71d582..2e42c712 100644 --- a/_doc/installation.rst +++ b/_doc/installation.rst @@ -103,13 +103,17 @@ Run the follwing command to fix it:: Autostart after reboot ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Check where habapp is installed + + which habapp To automatically start HABApp from the virtual environment after a reboot call:: nano /etc/systemd/system/habapp.service -and copy paste the following contents. If the user which is running openhab is not "openhab" replace accordingly:: +and copy paste the following contents. If the user which is running openhab is not "openhab" replace accordingly. +If your installation is not done in "/opt/habapp/bin" replace accordingly as well:: [Unit] Description=HABApp diff --git a/_doc/parameters.rst b/_doc/parameters.rst index e6a6321a..11e510ff 100644 --- a/_doc/parameters.rst +++ b/_doc/parameters.rst @@ -8,7 +8,7 @@ Parameters are values which can easily be changed without having to reload the r Values will be picked up during runtime as soon as they get edited in the corresponding file. If the file doesn't exist yet it will automatically be generated in the configured `param` folder. Parameters are perfect for boundaries (e.g. if value is below param switch something on). - +Currently there are is :class:`~HABApp.parameters.Parameter` and :class:`~HABApp.parameters.DictParameter` available. .. execute_code:: :hide_output: @@ -40,7 +40,7 @@ Parameters are perfect for boundaries (e.g. if value is below param switch somet self.listen_event('test_item', self.on_change_event, HABApp.core.events.ValueChangeEvent) - def on_change_event( event): + def on_change_event(self, event): # the parameter can be used like a normal variable, comparison works as expected if self.min_value < event.value: @@ -71,10 +71,6 @@ Created file: Changes in the file will be automatically picked up through :class:`~HABApp.parameters.Parameter`. -.. autoclass:: HABApp.parameters.Parameter - :members: - - .. automethod:: __init__ Validation ------------------------------ @@ -90,8 +86,16 @@ Example :hide_output: # hide + from pathlib import Path + + import HABApp from HABApp.parameters.parameters import _PARAMETERS _PARAMETERS['param_file_testrule'] = {'min_value': 10, 'Rule A': {'subkey1': {'subkey2': ['a', 'b', 'c']}}} + + # Patch values so we don't get errors + HABApp.config.CONFIG.directories.rules = Path('/my_rules/') + HABApp.config.CONFIG.directories.config = Path('/my_config/') + HABApp.config.CONFIG.directories.param = Path('/my_param/') # hide import HABApp @@ -106,7 +110,7 @@ Example validator = { 'Test': int, 'Key': { - 'mandatory': str, + 'mandatory_key': str, voluptuous.Optional('optional'): int } } @@ -119,33 +123,57 @@ Parameteres are not bound to rule instance and thus work everywhere in the rule It is possible to dynamically create rules from the contents of the parameter file. It's even possible to automatically reload rules if the parameter file has changed: -Just listen to the ``FileLoadEvent`` of the parameter file and create a ``FileLoadEvent`` for the rule file, too. +Just add the "reloads on" entry to the file. -Example +.. code-block:: yaml + :caption: my_param.yml + + key1: + v: 10 + key2: + v: 12 .. execute_code:: + :header_code: rule # hide from HABApp.parameters.parameters import _PARAMETERS - _PARAMETERS['param_file_testrule'] = {'min_value': 10, 'Rule A': {'subkey1': {'subkey2': ['a', 'b', 'c']}}} + _PARAMETERS['my_param'] = {'key1': {'v': 10}, 'key2': {'v': 12}} from tests import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() # hide + + import HABApp class MyRule(HABApp.Rule): def __init__(self, k, v): super().__init__() + print(f'{k}: {v}') - cfg = HABApp.Parameter( 'param_file_testrule').value # this will get the file content + + cfg = HABApp.DictParameter('my_param') # this will get the file content for k, v in cfg.items(): MyRule(k, v) # hide runner.tear_down() # hide + + + +Parameter classes +------------------------------ + +.. autoclass:: HABApp.parameters.Parameter + :members: + + +.. autoclass:: HABApp.parameters.DictParameter + :members: + diff --git a/_doc/rule.rst b/_doc/rule.rst index 43ea036e..2eb06a5b 100644 --- a/_doc/rule.rst +++ b/_doc/rule.rst @@ -94,12 +94,12 @@ Example:: # If you already have an item you can use the more convenient method of the item # to listen to the item events my_item = Item.get_item('MyItem') - my_item.listen_event(self.item_changed, ValueUpdateEvent) + my_item.listen_event(self.on_change, ValueUpdateEvent) - def on_change(event): + def on_change(self, event): assert isinstance(event, ValueChangeEvent), type(event) - def on_update(event): + def on_update(self, event): assert isinstance(event, ValueUpdateEvent), type(event) Scheduler @@ -198,27 +198,35 @@ If you want to assign a custom name, you can change the rule name easily by assi *rule_a.py*:: - class ClassA(Rule): + import HABApp + + class ClassA(HABApp.Rule): ... + def function_a(self): + ... + ClassA() *rule_b.py*:: + import HABApp + import typing + if typing.TYPE_CHECKING: # This is only here to allow - from .class_a import ClassA # type hints for the IDE + from .rule_a import ClassA # type hints for the IDE - class ClassB(Rule): + class ClassB(HABApp.Rule): ... - def function_a(self): + def function_b(self): r = self.get_rule('ClassA') # type: ClassA # The comment "# type: ClassA" will signal the IDE that the value returned from the # function is an instance of ClassA and thus provide checks and auto complete. # this calls the function on the instance - r.function_b() + r.function_a() diff --git a/conf_testing/rules/test_openhab_interface.py b/conf_testing/rules/test_openhab_interface.py index 985b0839..df3657b2 100644 --- a/conf_testing/rules/test_openhab_interface.py +++ b/conf_testing/rules/test_openhab_interface.py @@ -97,7 +97,6 @@ def test_openhab_item_not_found(self): def test_item_definition(self): self.openhab.get_item('TestGroupAVG') self.openhab.get_item('TestNumber') - self.openhab.get_item('TestNumber9') self.openhab.get_item('TestString') def test_metadata(self): diff --git a/tests/conftest.py b/tests/conftest.py index bae7a9df..1f2b3a63 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1 +1,36 @@ -from .helpers import parent_rule \ No newline at end of file +from .helpers import parent_rule, params +import HABApp +import pytest, asyncio +import typing + +import functools + +if typing.TYPE_CHECKING: + parent_rule = parent_rule + params = params + + +def raise_err(func): + # return async wrapper + if asyncio.iscoroutinefunction(func) or asyncio.iscoroutine(func): + @functools.wraps(func) + async def a(*args, **kwargs): + return await func(*args, **kwargs) + return a + + @functools.wraps(func) + def f(*args, **kwargs): + return func(*args, **kwargs) + + return f + + +@pytest.fixture(autouse=True, scope='function') +def show_errors(monkeypatch): + monkeypatch.setattr(HABApp.core.wrapper, 'ignore_exception', raise_err) + monkeypatch.setattr(HABApp.core.wrapper, 'log_exception', raise_err) + + +@pytest.yield_fixture(autouse=True, scope='function') +def event_loop(): + yield HABApp.core.const.loop diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index e3b17fe5..2569499c 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,2 +1,3 @@ from .sync_worker import SyncWorker -from .parent_rule import parent_rule \ No newline at end of file +from .parent_rule import parent_rule +from .parameters import params \ No newline at end of file diff --git a/tests/helpers/parameters.py b/tests/helpers/parameters.py new file mode 100644 index 00000000..8276d184 --- /dev/null +++ b/tests/helpers/parameters.py @@ -0,0 +1,28 @@ +from pathlib import Path + +import pytest + +import HABApp +import HABApp.parameters.parameters as Parameters + + +@pytest.fixture(scope="function") +def params(): + class DummyCfg: + class directories: + param: Path = Path(__file__).parent + + original = HABApp.CONFIG + HABApp.CONFIG = DummyCfg + # Parameters.ParameterFileWatcher.UNITTEST = True + # Parameters.setup(None, None) + yield Parameters + Parameters._PARAMETERS.clear() + + # delete possible created files + for f in DummyCfg.directories.param.iterdir(): + if f.name.endswith('.yml'): + f.unlink() + + HABApp.CONFIG = original + return None diff --git a/tests/test_core/test_files/__init__.py b/tests/test_core/test_files/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/test_core/test_files/test_file_dependencies.py b/tests/test_core/test_files/test_file_dependencies.py new file mode 100644 index 00000000..8b0ffe57 --- /dev/null +++ b/tests/test_core/test_files/test_file_dependencies.py @@ -0,0 +1,185 @@ +from pathlib import Path + +import HABApp +import logging +from HABApp.core.files.all import process, file_load_ok +from HABApp.core.files.file import HABAppFile, FileProperties +from ...helpers import SyncWorker + +FILE_PROPS = {} + + +@classmethod +def from_path(cls, name: str, path) -> HABAppFile: + return cls(name, MockFile(name), FILE_PROPS[name]) + + +class MockFile: + def __init__(self, name: str): + if name.startswith('params/'): + name = name[7:] + self.name = name + + def as_posix(self): + return f'/my_param/{self.name}' + + def is_file(self): + return True + + def __repr__(self): + return f'' + + +def test_reload_on(monkeypatch): + monkeypatch.setattr(HABAppFile, 'from_path', from_path) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'rules', Path('/my_rules/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'config', Path('/my_config/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'param', Path('/my_param/')) + + order = [] + + def process_event(event): + order.append(event.name) + file_load_ok(event.name) + + FILE_PROPS.clear() + FILE_PROPS['params/param1'] = FileProperties(depends_on=[], reloads_on=['params/param2']) + FILE_PROPS['params/param2'] = FileProperties() + + with SyncWorker()as sync: + sync.listen_events(HABApp.core.const.topics.FILES, process_event) + + process([MockFile('param2'), MockFile('param1')]) + + assert order == ['params/param1', 'params/param2', 'params/param1'] + order.clear() + + process([]) + assert order == [] + + process([MockFile('param2')]) + assert order == ['params/param2', 'params/param1'] + order.clear() + + process([MockFile('param1')]) + assert order == ['params/param1'] + order.clear() + + process([MockFile('param2')]) + assert order == ['params/param2', 'params/param1'] + order.clear() + + +def test_reload_dep(monkeypatch): + monkeypatch.setattr(HABAppFile, 'from_path', from_path) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'rules', Path('/my_rules/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'config', Path('/my_config/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'param', Path('/my_param/')) + + order = [] + + def process_event(event): + order.append(event.name) + file_load_ok(event.name) + + FILE_PROPS.clear() + FILE_PROPS['params/param1'] = FileProperties(depends_on=['params/param2'], reloads_on=['params/param2']) + FILE_PROPS['params/param2'] = FileProperties() + + with SyncWorker()as sync: + sync.listen_events(HABApp.core.const.topics.FILES, process_event) + + process([MockFile('param2'), MockFile('param1')]) + + assert order == ['params/param2', 'params/param1'] + order.clear() + + process([]) + assert order == [] + + process([MockFile('param2')]) + assert order == ['params/param2', 'params/param1'] + order.clear() + + process([MockFile('param1')]) + assert order == ['params/param1'] + order.clear() + + process([MockFile('param2')]) + assert order == ['params/param2', 'params/param1'] + order.clear() + + +def test_missing_dependencies(monkeypatch, caplog): + monkeypatch.setattr(HABAppFile, 'from_path', from_path) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'rules', Path('/my_rules/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'config', Path('/my_config/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'param', Path('/my_param/')) + + order = [] + + def process_event(event): + order.append(event.name) + file_load_ok(event.name) + + FILE_PROPS['params/param1'] = FileProperties(depends_on=['params/param4', 'params/param5']) + FILE_PROPS['params/param2'] = FileProperties(depends_on=['params/param4']) + FILE_PROPS['params/param3'] = FileProperties() + + with SyncWorker()as sync: + sync.listen_events(HABApp.core.const.topics.FILES, process_event) + + process([MockFile('param1'), MockFile('param2'), MockFile('param3')]) + + assert order == ['params/param3'] + order.clear() + + process([]) + assert order == [] + + msg1 = ( + 'HABApp.files', logging.ERROR, "File depends on file that doesn't exist: params/param4" + ) + msg2 = ( + 'HABApp.files', logging.ERROR, + "File depends on files that don't exist: params/param4, params/param5" + ) + + assert msg1 in caplog.record_tuples + assert msg2 in caplog.record_tuples + + +def test_missing_loads(monkeypatch, caplog): + monkeypatch.setattr(HABAppFile, 'from_path', from_path) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'rules', Path('/my_rules/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'config', Path('/my_config/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'param', Path('/my_param/')) + + order = [] + + def process_event(event): + order.append(event.name) + file_load_ok(event.name) + + FILE_PROPS['params/param1'] = FileProperties(reloads_on=['params/param4', 'params/param5']) + FILE_PROPS['params/param2'] = FileProperties(reloads_on=['params/param4']) + + with SyncWorker()as sync: + sync.listen_events(HABApp.core.const.topics.FILES, process_event) + + process([MockFile('param1'), MockFile('param2')]) + + assert order == ['params/param1', 'params/param2'] + order.clear() + + process([]) + assert order == [] + + msg1 = ( + 'HABApp.files', logging.WARNING, "File reloads on file that doesn't exist: params/param4" + ) + msg2 = ('HABApp.files', logging.WARNING, + "File reloads on files that don't exist: params/param4, params/param5") + + assert msg1 in caplog.record_tuples + assert msg2 in caplog.record_tuples diff --git a/tests/test_core/test_files/test_file_properties.py b/tests/test_core/test_files/test_file_properties.py new file mode 100644 index 00000000..32ec92da --- /dev/null +++ b/tests/test_core/test_files/test_file_properties.py @@ -0,0 +1,104 @@ +from HABApp.core.files.file_props import get_props +from HABApp.core.files.file import HABAppFile, CircularReferenceError, FileProperties, ALL +import pytest + + +def test_prop_1(): + _in = """# HABApp: +# depends on: +# - my_param.yml +# +# reloads on: +# - my_file.py +# This is my comment +# - other_file.py +""" + p = get_props(_in) + assert p.depends_on == ['my_param.yml'] + assert p.reloads_on == ['my_file.py'] + + +def test_prop_2(): + _in = """ + +# +# HABApp: +# depends on: +# - my_param.yml +# +# + +# reloads on: +# - my_file.py +# This is my comment +""" + p = get_props(_in) + assert p.depends_on == ['my_param.yml'] + assert p.reloads_on == [] + + +def test_prop_3(): + _in = """ + +# +# HABApp: +# depends on: +# - my_param1.yml +import asdf +# - my_param2.yml +""" + p = get_props(_in) + assert p.depends_on == ['my_param1.yml'] + assert p.reloads_on == [] + + +def test_prop_missing(): + _in = """import bla bla bla +""" + p = get_props(_in) + assert p.depends_on == [] + assert p.reloads_on == [] + + +def test_deps(): + ALL.clear() + ALL['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(depends_on=['name2'])) + ALL['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties()) + + f1.check_properties() + f2.check_properties() + + assert f2.can_be_loaded() + assert not f1.can_be_loaded() + + f2.is_loaded = True + assert f1.can_be_loaded() + + +def test_reloads(): + ALL.clear() + ALL['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(reloads_on=['name2', 'asdf'])) + ALL['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties()) + + f1.check_properties() + assert f1.properties.reloads_on == ['name2', 'asdf'] + assert f2.properties.reloads_on == [] + + +def test_circ(): + ALL.clear() + ALL['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(depends_on=['name2'])) + ALL['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties(depends_on=['name3'])) + ALL['name3'] = f3 = HABAppFile('name3', 'path3', FileProperties(depends_on=['name1'])) + + with pytest.raises(CircularReferenceError) as e: + f1.check_properties() + assert str(e.value) == "name1 -> name2 -> name3 -> name1" + + with pytest.raises(CircularReferenceError) as e: + f2.check_properties() + assert str(e.value) == "name2 -> name3 -> name1 -> name2" + + with pytest.raises(CircularReferenceError) as e: + f3.check_properties() + assert str(e.value) == "name3 -> name1 -> name2 -> name3" diff --git a/tests/test_core/test_files/test_rel_name.py b/tests/test_core/test_files/test_rel_name.py new file mode 100644 index 00000000..e8e280a5 --- /dev/null +++ b/tests/test_core/test_files/test_rel_name.py @@ -0,0 +1,35 @@ +from pathlib import Path + +import pytest + +import HABApp +from HABApp.core.files import name_from_path, path_from_name + + +@pytest.fixture +def cfg(monkeypatch): + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'rules', Path('c:/HABApp/my_rules/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'config', Path('c:/HABApp/my_config/')) + monkeypatch.setattr(HABApp.config.CONFIG.directories, 'param', Path('c:/HABApp/my_param/')) + + yield None + + +def cmp(path: Path, name: str): + assert name_from_path(path) == name + assert path_from_name(name) == path + + +def test_from_path(cfg): + cmp(Path('c:/HABApp/my_rules/rule.py'), 'rules/rule.py') + cmp(Path('c:/HABApp/my_config/params.yml'), 'configs/params.yml') + cmp(Path('c:/HABApp/my_param/cfg.yml'), 'params/cfg.yml') + + cmp(Path('c:/HABApp/my_rules/my_folder1/folder2/rule.py'), 'rules/my_folder1/folder2/rule.py') + cmp(Path('c:/HABApp/my_config/my_folder2/cfg.yml'), 'configs/my_folder2/cfg.yml') + cmp(Path('c:/HABApp/my_param/my_folder3/cfg.yml'), 'params/my_folder3/cfg.yml') + + +def test_err(cfg): + with pytest.raises(ValueError): + name_from_path(Path('c:/HABApp/rules/rule.py')) diff --git a/tests/test_core/test_items/test_item_aggregation.py b/tests/test_core/test_items/test_item_aggregation.py index 62c38a2d..d86ccf86 100644 --- a/tests/test_core/test_items/test_item_aggregation.py +++ b/tests/test_core/test_items/test_item_aggregation.py @@ -5,12 +5,6 @@ import HABApp -@pytest.yield_fixture() -def event_loop(): - HABApp.core.WrappedFunction._EVENT_LOOP = HABApp.core.const.loop - yield HABApp.core.const.loop - - @pytest.mark.asyncio async def test_aggregation_item(): agg = HABApp.core.items.AggregationItem.get_create_item('MyAggregation') diff --git a/tests/test_core/test_items/test_item_times.py b/tests/test_core/test_items/test_item_times.py index a3ce456b..c3ede5bb 100644 --- a/tests/test_core/test_items/test_item_times.py +++ b/tests/test_core/test_items/test_item_times.py @@ -9,11 +9,6 @@ from HABApp.core.items.base_item import ChangedTime, UpdatedTime -@pytest.yield_fixture() -def event_loop(): - yield HABApp.core.const.loop - - @pytest.fixture(scope="function") def u(): a = UpdatedTime('test', datetime.now(tz=pytz.utc)) diff --git a/tests/test_core/test_utilities.py b/tests/test_core/test_utilities.py index 5c8e81cd..1a914e6d 100644 --- a/tests/test_core/test_utilities.py +++ b/tests/test_core/test_utilities.py @@ -2,15 +2,9 @@ import pytest -import HABApp from HABApp.core.lib import PendingFuture -@pytest.yield_fixture() -def event_loop(): - yield HABApp.core.const.loop - - @pytest.mark.asyncio async def test_pending_future(): a = 0 diff --git a/tests/test_openhab/test_connection/test_connection_waiter.py b/tests/test_openhab/test_connection/test_connection_waiter.py index 42745ec3..fee3097a 100644 --- a/tests/test_openhab/test_connection/test_connection_waiter.py +++ b/tests/test_openhab/test_connection/test_connection_waiter.py @@ -1,16 +1,9 @@ import asyncio + import pytest -import HABApp from HABApp.openhab.connection_handler.http_connection_waiter import WaitBetweenConnects - -@pytest.yield_fixture() -def event_loop(): - HABApp.core.WrappedFunction._EVENT_LOOP = HABApp.core.const.loop - yield HABApp.core.const.loop - - waited = -1 diff --git a/tests/test_parameters/test_base.py b/tests/test_parameters/test_base.py new file mode 100644 index 00000000..b25b552f --- /dev/null +++ b/tests/test_parameters/test_base.py @@ -0,0 +1,39 @@ +import HABApp +import typing + +from HABApp import Parameter +from tests.conftest import params + +if typing.TYPE_CHECKING: + params = params + + +def test_simple_key_creation(params: HABApp.parameters.parameters): + + Parameter('file', 'key') + assert params.get_parameter_file('file') == {'key': 'ToDo'} + + Parameter('file', 'key2') + assert params.get_parameter_file('file') == {'key': 'ToDo', 'key2': 'ToDo'} + + +def test_structured_key_creation(params: HABApp.parameters.parameters): + Parameter('file', 'key1', 'key1') + Parameter('file', 'key1', 'key2') + assert params.get_parameter_file('file') == {'key1': {'key1': 'ToDo', 'key2': 'ToDo'}} + + +def test_structured_default_value(params: HABApp.parameters.parameters): + Parameter('file', 'key1', 'key1', default_value=123) + Parameter('file', 'key1', 'key2', default_value=[1, 2, 3]) + assert params.get_parameter_file('file') == {'key1': {'key1': 123, 'key2': [1, 2, 3]}} + + +def test_lookup(params: HABApp.parameters.parameters): + data = {'key1': {'key2': 'value2'}} + params.set_parameter_file('file1', data) + p = Parameter('file1', 'key1', 'key2') + assert p == 'value2' + + data['key1']['key2'] = 3 + assert p == 3 diff --git a/tests/test_parameters/test_dict_parameter.py b/tests/test_parameters/test_dict_parameter.py new file mode 100644 index 00000000..1e449ae8 --- /dev/null +++ b/tests/test_parameters/test_dict_parameter.py @@ -0,0 +1,49 @@ +import pytest +import typing + +import HABApp +from HABApp import DictParameter +from tests.conftest import params + +if typing.TYPE_CHECKING: + params = params + + +def test_operators(params: HABApp.parameters.parameters): + params.set_parameter_file('file', {'key': {1: 2, 3: 4}}) + p = DictParameter('file', 'key') + assert p == {1: 2, 3: 4} + assert p != {1: 2, 3: 5} + + assert 1 in p + assert 3 in p + assert 2 not in p + + assert p[1] == 2 + assert p[3] == 4 + + assert [k for k in p] == [1, 3] + + +def test_funcs(params: HABApp.parameters.parameters): + params.set_parameter_file('file', {'key': {1: 2, 3: 4}}) + p = DictParameter('file', 'key') + + assert len(p) == 2 + + assert list(p.keys()) == [1, 3] + assert list(p.values()) == [2, 4] + assert {k: v for k, v in p.items()} == {1: 2, 3: 4} + + assert p.get(5) is None + assert p.get(5, 'asdf') == 'asdf' + + +def test_exception(params: HABApp.parameters.parameters): + params.set_parameter_file('file', {'key': 'value'}) + p = DictParameter('file', 'key') + + with pytest.raises(ValueError) as e: + _ = p.value + + assert str(e.value) == 'Value "value" for DictParameter is not a dict! ()' diff --git a/tests/test_parameters/test_parameter.py b/tests/test_parameters/test_parameter.py new file mode 100644 index 00000000..f68c9669 --- /dev/null +++ b/tests/test_parameters/test_parameter.py @@ -0,0 +1,52 @@ +import HABApp +import typing + +from HABApp import Parameter +from tests.conftest import params + +if typing.TYPE_CHECKING: + params = params + + +def test_int_operators(params: HABApp.parameters.parameters): + params.set_parameter_file('file', {'key': 5}) + p = Parameter('file', 'key') + assert p == 5 + assert p != 6 + + assert p < 6 + assert p <= 5 + assert p >= 5 + assert p > 4 + + params.set_parameter_file('file', {'key': 15}) + assert not p < 6 + assert not p <= 5 + assert p >= 5 + assert p > 4 + + +def test_float_operators(params: HABApp.parameters.parameters): + params.set_parameter_file('file', {'key': 5.5}) + p = Parameter('file', 'key') + + assert p < 6 + assert not p <= 5 + assert p >= 5 + assert p > 4 + + +def test_arithmetic(params: HABApp.parameters.parameters): + params.set_parameter_file('file', {'key': 1}) + p = Parameter('file', 'key') + + assert int(p) == 1 + assert p + 1 == 2 + assert p - 1 == 0 + assert p * 3 == 3 + assert p / 2 == 0.5 + assert p // 2 == 0 + assert p << 1 == 2 + assert p >> 1 == 0 + assert p | 2 == 3 + assert p & 2 == 0 diff --git a/tests/test_rule/test_process.py b/tests/test_rule/test_process.py index 304b82f4..6fc4f213 100644 --- a/tests/test_rule/test_process.py +++ b/tests/test_rule/test_process.py @@ -1,17 +1,12 @@ import asyncio -import pytest import sys -import HABApp +import pytest + from HABApp.rule import Rule from ..rule_runner import SimpleRuleRunner -@pytest.yield_fixture() -def event_loop(): - yield HABApp.core.const.loop - - class ProcRule(Rule): def __init__(self): super().__init__() diff --git a/tests/test_rule/test_rule_params.py b/tests/test_rule/test_rule_params.py deleted file mode 100644 index 997fbedd..00000000 --- a/tests/test_rule/test_rule_params.py +++ /dev/null @@ -1,103 +0,0 @@ -import pytest -from pathlib import Path - -import HABApp -from HABApp.parameters.parameter import Parameter -import HABApp.parameters.parameters as Parameters - - -@pytest.fixture(scope="function") -def params(): - class DummyCfg: - class directories: - param: Path = Path(__file__).parent - - original = HABApp.CONFIG - HABApp.CONFIG = DummyCfg - # Parameters.ParameterFileWatcher.UNITTEST = True - # Parameters.setup(None, None) - yield None - Parameters._PARAMETERS.clear() - - # delete possible created files - for f in DummyCfg.directories.param.iterdir(): - if f.name.endswith('.yml'): - f.unlink() - - HABApp.CONFIG = original - - -def test_lookup(params): - - data = {'key1': {'key2': 'value2'}} - Parameters.set_parameter_file('file1', data) - p = Parameter('file1', 'key1', 'key2') - assert p == 'value2' - - data['key1']['key2'] = 3 - assert p == 3 - - -def test_int_operators(params): - Parameters.set_parameter_file('file', {'key': 5}) - p = Parameter('file', 'key') - assert p == 5 - assert p != 6 - - assert p < 6 - assert p <= 5 - assert p >= 5 - assert p > 4 - - Parameters.set_parameter_file('file', {'key': 15}) - assert not p < 6 - assert not p <= 5 - assert p >= 5 - assert p > 4 - - -def test_float_operators(params): - Parameters.set_parameter_file('file', {'key': 5.5}) - p = Parameter('file', 'key') - - assert p < 6 - assert not p <= 5 - assert p >= 5 - assert p > 4 - - -def test_arithmetic(params): - Parameters.set_parameter_file('file', {'key': 1}) - p = Parameter('file', 'key') - - assert int(p) == 1 - assert p + 1 == 2 - assert p - 1 == 0 - assert p * 3 == 3 - assert p / 2 == 0.5 - assert p // 2 == 0 - assert p << 1 == 2 - assert p >> 1 == 0 - assert p | 2 == 3 - assert p & 2 == 0 - - -def test_simple_key_creation(params): - - Parameter('file', 'key') - assert Parameters.get_parameter_file('file') == {'key': 'ToDo'} - - Parameter('file', 'key2') - assert Parameters.get_parameter_file('file') == {'key': 'ToDo', 'key2': 'ToDo'} - - -def test_structured_key_creation(params): - Parameter('file', 'key1', 'key1') - Parameter('file', 'key1', 'key2') - assert Parameters.get_parameter_file('file') == {'key1': {'key1': 'ToDo', 'key2': 'ToDo'}} - - -def test_structured_default_value(params): - Parameter('file', 'key1', 'key1', default_value=123) - Parameter('file', 'key1', 'key2', default_value=[1, 2, 3]) - assert Parameters.get_parameter_file('file') == {'key1': {'key1': 123, 'key2': [1, 2, 3]}}