diff --git a/.github/workflows/run_tox.yml b/.github/workflows/run_tox.yml index ef4a1ad7..35e324a3 100644 --- a/.github/workflows/run_tox.yml +++ b/.github/workflows/run_tox.yml @@ -1,6 +1,6 @@ name: Tests -on: [push] +on: [push, pull_request] jobs: test: diff --git a/HABApp/__version__.py b/HABApp/__version__.py index 1d819b36..66ec0bcf 100644 --- a/HABApp/__version__.py +++ b/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '0.17.1' +__version__ = '0.18.0' diff --git a/HABApp/core/Items.py b/HABApp/core/Items.py index ff7b143e..2449ae7e 100644 --- a/HABApp/core/Items.py +++ b/HABApp/core/Items.py @@ -6,7 +6,15 @@ class ItemNotFoundException(Exception): - pass + def __init__(self, name: str): + super().__init__(f'Item {name} does not exist!') + self.name: str = name + + +class ItemAlreadyExistsError(Exception): + def __init__(self, name: str): + super().__init__(f'Item {name} does already exist and can not be added again!') + self.name: str = name def item_exists(name: str) -> bool: @@ -17,7 +25,7 @@ def get_item(name: str) -> __BaseItem: try: return _ALL_ITEMS[name] except KeyError: - raise ItemNotFoundException(f'Item {name} does not exist!') from None + raise ItemNotFoundException(name) from None def get_all_items() -> typing.List[__BaseItem]: @@ -30,16 +38,33 @@ def get_all_item_names() -> typing.List[str]: def create_item(name: str, item_factory, initial_value=None) -> __BaseItem: assert issubclass(item_factory, __BaseItem), item_factory - _ALL_ITEMS[name] = new_item = item_factory(name, initial_value=initial_value) + new_item = item_factory(name, initial_value=initial_value) + add_item(new_item) return new_item -def set_item(item: __BaseItem): +def add_item(item: __BaseItem): assert isinstance(item, __BaseItem), type(item) - _ALL_ITEMS[item.name] = item + name = item.name + + existing = _ALL_ITEMS.get(name) + if existing is not None: + # adding the same item multiple times will not cause an exception + if existing is item: + return None + + # adding a new item with the same name raises an exception + raise ItemAlreadyExistsError(name) + + _ALL_ITEMS[name] = item + item._on_item_add() def pop_item(name: str) -> __BaseItem: - item = _ALL_ITEMS.pop(name) + try: + item = _ALL_ITEMS.pop(name) + except KeyError: + raise ItemNotFoundException(name) from None + item._on_item_remove() return item diff --git a/HABApp/core/event_bus_listener.py b/HABApp/core/event_bus_listener.py index 3b76a565..120974d6 100644 --- a/HABApp/core/event_bus_listener.py +++ b/HABApp/core/event_bus_listener.py @@ -1,18 +1,30 @@ import HABApp from HABApp.core.events import AllEvents from . import WrappedFunction +from typing import Optional, Any class EventBusListener: - def __init__(self, topic, callback, event_type=AllEvents): + def __init__(self, topic, callback, event_type=AllEvents, + prop_name1: Optional[str] = None, prop_value1: Optional[Any] = None, + prop_name2: Optional[str] = None, prop_value2: Optional[Any] = None, + ): assert isinstance(topic, str), type(topic) assert isinstance(callback, WrappedFunction) + assert prop_name1 is None or isinstance(prop_name1, str), prop_name1 + assert prop_name2 is None or isinstance(prop_name2, str), prop_name2 self.topic: str = topic self.func: WrappedFunction = callback self.event_filter = event_type + # Property filters + self.prop_name1 = prop_name1 + self.prop_value1 = prop_value1 + self.prop_name2 = prop_name2 + self.prop_value2 = prop_value2 + self.__is_all: bool = self.event_filter is AllEvents self.__is_single: bool = not isinstance(self.event_filter, (list, tuple, set)) @@ -25,12 +37,28 @@ def notify_listeners(self, event): # single filter if self.__is_single: if isinstance(event, self.event_filter): + # If we have property filters wie only trigger when value is set accordingly + if self.prop_name1 is not None: + if getattr(event, self.prop_name1, None) != self.prop_value1: + return None + if self.prop_name2 is not None: + if getattr(event, self.prop_name2, None) != self.prop_value2: + return None + self.func.run(event) return None # Make it possible to specify multiple classes for cls in self.event_filter: if isinstance(event, cls): + # If we have property filters wie only trigger when value is set accordingly + if self.prop_name1 is not None: + if getattr(event, self.prop_name1, None) != self.prop_value1: + return None + if self.prop_name2 is not None: + if getattr(event, self.prop_name2, None) != self.prop_value2: + return None + self.func.run(event) return None @@ -42,5 +70,12 @@ def desc(self): # return description _type = str(self.event_filter) if _type.startswith(" BaseWatch: + def add_watch(self, secs: typing.Union[int, float, timedelta]) -> BaseWatch: + if isinstance(secs, timedelta): + secs = secs.total_seconds() assert secs > 0, secs # don't add the watch two times for t in self.tasks: - if not t._fut.is_canceled and t._fut.secs == secs: + if not t.fut.is_canceled and t.fut.secs == secs: + log.warning(f'Watcher {self.WATCH.__name__} ({t.fut.secs}s) for {self.name} has already been created') return t + w = self.WATCH(self.name, secs) self.tasks.append(w) + log.debug(f'Added {self.WATCH.__name__} ({w.fut.secs}s) for {self.name}') return w @log_exception async def schedule_events(self): - clean = False + canceled = [] for t in self.tasks: - if t._fut.is_canceled: - clean = True + if t.fut.is_canceled: + canceled.append(t) else: - t._fut.reset() + t.fut.reset() # remove canceled tasks - if clean: - self.tasks = [t for t in self.tasks if not t._fut.is_canceled] + if canceled: + for c in canceled: + self.tasks.remove(c) + log.debug(f'Removed {self.WATCH.__name__} ({c.fut.secs}s) for {self.name}') return None diff --git a/HABApp/core/items/base_item_watch.py b/HABApp/core/items/base_item_watch.py index 8d17f628..55392095 100644 --- a/HABApp/core/items/base_item_watch.py +++ b/HABApp/core/items/base_item_watch.py @@ -1,29 +1,41 @@ import asyncio +import logging import typing import HABApp -from ..const import loop from HABApp.core.lib import PendingFuture +from ..const import loop from ..events import ItemNoChangeEvent, ItemNoUpdateEvent +log = logging.getLogger('HABApp') + class BaseWatch: EVENT: typing.Union[typing.Type[ItemNoUpdateEvent], typing.Type[ItemNoChangeEvent]] def __init__(self, name: str, secs: typing.Union[int, float]): - self._fut = PendingFuture(self._post_event, secs) - self._name: str = name + self.fut = PendingFuture(self._post_event, secs) + self.name: str = name async def _post_event(self): - HABApp.core.EventBus.post_event(self._name, self.EVENT(self._name, self._fut.secs)) + HABApp.core.EventBus.post_event(self.name, self.EVENT(self.name, self.fut.secs)) async def __cancel_watch(self): - self._fut.cancel() + self.fut.cancel() + log.debug(f'Canceled {self.__class__.__name__} ({self.fut.secs}s) for {self.name}') def cancel(self): """Cancel the item watch""" asyncio.run_coroutine_threadsafe(self.__cancel_watch(), loop) + def listen_event(self, callback: typing.Callable[[typing.Any], typing.Any]) -> 'HABApp.core.EventBusListener': + rule = HABApp.rule.get_parent_rule() + cb = HABApp.core.WrappedFunction(callback, name=rule._get_cb_name(callback)) + listener = HABApp.core.EventBusListener( + self.name, cb, self.EVENT, 'seconds', self.fut.secs + ) + return rule._add_event_listener(listener) + class ItemNoUpdateWatch(BaseWatch): EVENT = ItemNoUpdateEvent diff --git a/HABApp/core/items/item.py b/HABApp/core/items/item.py index 3e736582..1228427c 100644 --- a/HABApp/core/items/item.py +++ b/HABApp/core/items/item.py @@ -19,7 +19,7 @@ def get_create_item(cls, name: str, initial_value=None): item = HABApp.core.Items.get_item(name) except HABApp.core.Items.ItemNotFoundException: item = cls(name, initial_value) - HABApp.core.Items.set_item(item) + HABApp.core.Items.add_item(item) assert isinstance(item, cls), f'{cls} != {type(item)}' return item diff --git a/HABApp/core/items/item_aggregation.py b/HABApp/core/items/item_aggregation.py index ec14b365..c56a1f87 100644 --- a/HABApp/core/items/item_aggregation.py +++ b/HABApp/core/items/item_aggregation.py @@ -23,7 +23,7 @@ def get_create_item(cls, name: str): item = HABApp.core.Items.get_item(name) except HABApp.core.Items.ItemNotFoundException: item = cls(name) - HABApp.core.Items.set_item(item) + HABApp.core.Items.add_item(item) assert isinstance(item, cls), f'{cls} != {type(item)}' return item diff --git a/HABApp/core/items/tmp_data.py b/HABApp/core/items/tmp_data.py new file mode 100644 index 00000000..fc1ef455 --- /dev/null +++ b/HABApp/core/items/tmp_data.py @@ -0,0 +1,82 @@ +import logging +import typing +from datetime import datetime, timedelta + +import HABApp +from HABApp.core.lib import PendingFuture +from .base_item_watch import BaseWatch + +if typing.TYPE_CHECKING: + from .base_item import BaseItem + + +TMP_DATA: typing.Dict[str, 'TmpItemData'] = {} + + +class TmpItemData: + def __init__(self): + self.ts = datetime.now() + self.update: typing.Set[BaseWatch] = set() + self.change: typing.Set[BaseWatch] = set() + + def add_tasks(self, update, change): + self.ts = datetime.now() + self.update.update(update) + self.change.update(change) + + self.clean() + + def clean(self): + # remove canceled + for obj in (self.update, self.change): + canceled = [k for k in obj if k.fut.is_canceled] + for p in canceled: + obj.remove(p) + + +def add_tmp_data(item: 'BaseItem'): + if not item._last_update.tasks and not item._last_change.tasks: + return None + + data = TMP_DATA.setdefault(item.name, TmpItemData()) + data.add_tasks(item._last_update.tasks, item._last_change.tasks) + + CLEANUP.reset(thread_safe=True) + + +def restore_tmp_data(item: 'BaseItem'): + if item.name not in TMP_DATA: + return None + + data = TMP_DATA.pop(item.name) + data.clean() + + for t in data.update: + item._last_update.tasks.append(t) + for t in data.change: + item._last_change.tasks.append(t) + + +async def clean_tmp_data(): + now = datetime.now() + diff_max = timedelta(seconds=CLEANUP.secs) + + to_del = [] + for name, obj in TMP_DATA.items(): + diff = now - obj.ts + if diff < diff_max: + continue + + to_del.append(name) + + # show a warning because otherwise it's not clear what is happening + w = HABApp.core.logger.HABAppWarning(logging.getLogger('HABApp.Item')) + w.add(f'Item {name} has been deleted {diff.total_seconds():.1f}s ago even though it has item watchers. ' + f'If it will be added again the watchers have to be created again, too!') + w.dump() + + for name in to_del: + TMP_DATA.pop(name) + + +CLEANUP = PendingFuture(clean_tmp_data, 15) diff --git a/HABApp/core/lib/pending_future.py b/HABApp/core/lib/pending_future.py index 2a927210..088377e3 100644 --- a/HABApp/core/lib/pending_future.py +++ b/HABApp/core/lib/pending_future.py @@ -1,7 +1,8 @@ import asyncio import typing -from asyncio import Task, ensure_future, sleep +from asyncio import Task, ensure_future, sleep, run_coroutine_threadsafe from typing import Any, Awaitable, Callable, Optional +from HABApp.core.const import loop class PendingFuture: @@ -24,7 +25,7 @@ def cancel(self): self.task.cancel() self.task = None - def reset(self): + def reset(self, thread_safe=False): if self.is_canceled: return None @@ -34,8 +35,11 @@ def reset(self): self.task.cancel() self.task = None - # todo: rename to asyncio.create_task once we go py3.7 only - self.task = ensure_future(self.__countdown()) + if thread_safe: + self.task = run_coroutine_threadsafe(self.__countdown(), loop) + else: + # todo: rename to asyncio.create_task once we go py3.7 only + self.task = ensure_future(self.__countdown()) async def __countdown(self): try: diff --git a/HABApp/core/wrapper.py b/HABApp/core/wrapper.py index 97529a31..4c4287ee 100644 --- a/HABApp/core/wrapper.py +++ b/HABApp/core/wrapper.py @@ -25,10 +25,10 @@ re.compile(r'[/\\]wrappedfunction.py$'), # Don't print stack for used libraries - re.compile(r'[/\\]asyncio[/\\]\w+.py$'), - re.compile(r'[/\\]aiohttp[/\\]\w+.py$'), - re.compile(r'[/\\]voluptuous[/\\]\w+.py$'), - re.compile(r'[/\\]pydantic[/\\]\w+.py$'), + re.compile(r'[/\\](site-packages|lib|python\d\.\d)[/\\]asyncio[/\\]'), + re.compile(r'[/\\]site-packages[/\\]aiohttp[/\\]'), + re.compile(r'[/\\]site-packages[/\\]voluptuous[/\\]'), + re.compile(r'[/\\]site-packages[/\\]pydantic[/\\]'), ) SKIP_TB = tuple(re.compile(k.pattern.replace('$', ', ')) for k in SUPPRESSED_PATHS) diff --git a/HABApp/openhab/connection_handler/func_sync.py b/HABApp/openhab/connection_handler/func_sync.py index 0d70fe3a..17b3fc82 100644 --- a/HABApp/openhab/connection_handler/func_sync.py +++ b/HABApp/openhab/connection_handler/func_sync.py @@ -112,7 +112,7 @@ def get_item(item_name: str, metadata: Optional[str] = None) -> OpenhabItemDefin """Return the complete OpenHAB item definition :param item_name: name of the item or item - :param metadata: metadata to include (optional) + :param metadata: metadata to include (optional, comma separated or search expression) :return: """ if isinstance(item_name, HABApp.openhab.items.base_item.BaseValueItem): diff --git a/HABApp/openhab/connection_logic/connection.py b/HABApp/openhab/connection_logic/connection.py index 5d113b44..c2084efa 100644 --- a/HABApp/openhab/connection_logic/connection.py +++ b/HABApp/openhab/connection_logic/connection.py @@ -70,11 +70,13 @@ def on_sse_event(event_dict: dict): item = existing_item else: log.warning(f'Item changed type from {existing_item.__class__} to {item.__class__}') + # remove the item so it can be added again + Items.pop_item(item.name) except Items.ItemNotFoundException: pass # always overwrite with new definition - Items.set_item(item) + Items.add_item(item) elif isinstance(event, HABApp.openhab.events.ItemRemovedEvent): Items.pop_item(event.name) diff --git a/HABApp/openhab/connection_logic/plugin_load_items.py b/HABApp/openhab/connection_logic/plugin_load_items.py index f278ce31..a5f51a5c 100644 --- a/HABApp/openhab/connection_logic/plugin_load_items.py +++ b/HABApp/openhab/connection_logic/plugin_load_items.py @@ -36,7 +36,7 @@ async def on_connect_function(self): pass # create new item or change item type - Items.set_item(new_item) + Items.add_item(new_item) # remove items which are no longer available ist = set(Items.get_all_item_names()) @@ -65,7 +65,7 @@ async def on_connect_function(self): thing = Thing(name) thing.status = t_dict['statusInfo']['status'] - HABApp.core.Items.set_item(thing) + HABApp.core.Items.add_item(thing) # remove things which were deleted ist = set(HABApp.core.Items.get_all_item_names()) diff --git a/HABApp/rule/rule.py b/HABApp/rule/rule.py index 63cb6472..9a563bb1 100644 --- a/HABApp/rule/rule.py +++ b/HABApp/rule/rule.py @@ -69,7 +69,6 @@ def __init__(self): self.register_on_unload(self.__cleanup_rule) self.register_on_unload(self.__cleanup_objs) - # suggest a rule name if it is not self.rule_name: str = self.__rule_file.suggest_rule_name(self) @@ -132,7 +131,7 @@ def listen_event(self, name: typing.Union[HABApp.core.items.BaseValueItem, str], :class:`~HABApp.core.ValueChangeEvent` which will also trigger on changes/update from openhab or mqtt. """ - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) listener = HABApp.core.EventBusListener( name.name if isinstance(name, HABApp.core.items.BaseValueItem) else name, cb, event_type ) @@ -152,7 +151,7 @@ def execute_subprocess(self, callback, program, *args, capture_output=True): """ assert isinstance(program, str), type(program) - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) asyncio.run_coroutine_threadsafe( async_subprocess_exec(cb.run, program, *args, capture_output=capture_output), @@ -171,7 +170,7 @@ def run_every(self, :param args: |param_scheduled_cb_args| :param kwargs: |param_scheduled_cb_kwargs| """ - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = ReoccurringScheduledCallback(cb, *args, **kwargs) future_event.interval(interval) self.__future_events.append(future_event) @@ -186,7 +185,7 @@ def run_on_sun(self, sun_event: str, callback, *args, run_if_missed=False, **kwa :param args: |param_scheduled_cb_args| :param kwargs: |param_scheduled_cb_kwargs| """ - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = SunScheduledCallback(cb, *args, **kwargs) future_event.sun_trigger(sun_event) future_event._calculate_next_call() @@ -221,7 +220,7 @@ def run_on_day_of_week(self, continue weekdays[i] = lookup[val.lower()] - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = DayOfWeekScheduledCallback(cb, *args, **kwargs) future_event.weekdays(weekdays) future_event.time(time) @@ -237,7 +236,7 @@ def run_on_every_day(self, time: datetime.time, callback, *args, **kwargs) -> Da :param kwargs: |param_scheduled_cb_kwargs| """ assert isinstance(time, datetime.time), type(time) - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = DayOfWeekScheduledCallback(cb, *args, **kwargs) future_event.weekdays('all') future_event.time(time) @@ -253,7 +252,7 @@ def run_on_workdays(self, time: datetime.time, callback, *args, **kwargs) -> Day :param kwargs: |param_scheduled_cb_kwargs| """ assert isinstance(time, datetime.time), type(time) - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = DayOfWeekScheduledCallback(cb, *args, **kwargs) future_event.weekdays('workday') future_event.time(time) @@ -269,7 +268,7 @@ def run_on_weekends(self, time: datetime.time, callback, *args, **kwargs) -> Day :param kwargs: |param_scheduled_cb_kwargs| """ assert isinstance(time, datetime.time), type(time) - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = DayOfWeekScheduledCallback(cb, *args, **kwargs) future_event.weekdays('weekend') future_event.time(time) @@ -321,7 +320,7 @@ def run_at(self, date_time: TYPING_DATE_TIME, callback, *args, **kwargs) -> OneT :param args: |param_scheduled_cb_args| :param kwargs: |param_scheduled_cb_kwargs| """ - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = OneTimeCallback(cb, *args, **kwargs) future_event.set_run_time(date_time) self.__future_events.append(future_event) @@ -339,7 +338,7 @@ def run_in(self, seconds: typing.Union[int, datetime.timedelta], callback, *args assert isinstance(seconds, (int, datetime.timedelta)), f'{seconds} ({type(seconds)})' fut = datetime.timedelta(seconds=seconds) if not isinstance(seconds, datetime.timedelta) else seconds - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = OneTimeCallback(cb, *args, **kwargs) future_event.set_run_time(fut) self.__future_events.append(future_event) @@ -353,7 +352,7 @@ def run_soon(self, callback, *args, **kwargs) -> OneTimeCallback: :param args: |param_scheduled_cb_args| :param kwargs: |param_scheduled_cb_kwargs| """ - cb = HABApp.core.WrappedFunction(callback, name=self.__get_rule_name(callback)) + cb = HABApp.core.WrappedFunction(callback, name=self._get_cb_name(callback)) future_event = OneTimeCallback(cb, *args, **kwargs) future_event.set_run_time(None) self.__future_events.append(future_event) @@ -479,9 +478,14 @@ def get_rule_parameter(self, file_name: str, *keys, default_value='ToDo'): # ----------------------------------------------------------------------------------------------------------------- # internal functions # ----------------------------------------------------------------------------------------------------------------- - def __get_rule_name(self, callback): + def _get_cb_name(self, callback): return f'{self.rule_name}.{callback.__name__}' if self.rule_name else None + def _add_event_listener(self, listener: HABApp.core.EventBusListener) -> HABApp.core.EventBusListener: + self.__event_listener.append(listener) + HABApp.core.EventBus.add_listener(listener) + return listener + @HABApp.core.wrapper.log_exception def _check_rule(self): diff --git a/HABApp/rule_manager/rule_file.py b/HABApp/rule_manager/rule_file.py index cd0be3f5..094cba11 100644 --- a/HABApp/rule_manager/rule_file.py +++ b/HABApp/rule_manager/rule_file.py @@ -83,10 +83,11 @@ def load(self) -> bool: rule._unload() return False - len_found = len(created_rules) - if not len_found: + if not created_rules: log.warning(f'Found no instances of HABApp.Rule in {str(self.path)}') - else: + return True + + with ign: for rule in created_rules: # ensure that we have a rule name rule.rule_name = self.suggest_rule_name(rule) @@ -98,4 +99,12 @@ def load(self) -> bool: self.rules[rule.rule_name] = rule log.info(f'Added rule "{rule.rule_name}" from {self.path.name}') + if ign.raised_exception: + # unload all rule instances which might have already been created otherwise they might + # still listen to events and do stuff + for rule in created_rules: + with ign: + rule._unload() + return False + return True diff --git a/_doc/advanced_usage.rst b/_doc/advanced_usage.rst index dd776d96..1aa5be44 100644 --- a/_doc/advanced_usage.rst +++ b/_doc/advanced_usage.rst @@ -93,7 +93,7 @@ Example AggregationItem ------------------------------ -The aggregation item is an item which takes the values of another item as an input. +The aggregation item is an item which takes the values of another item in a time period as an input. It then allows to process these values and generate an aggregated output based on it. The item makes implementing time logic like "Has it been dark for the last hour?" or "Was there frost during the last six hours?" really easy. @@ -119,4 +119,56 @@ It will automatically update and always reflect the latest changes of ``MyInputI .. autoclass:: HABApp.core.items.AggregationItem - :members: \ No newline at end of file + :members: + + +Mocking OpenHAB items and events for tests +-------------------------------------------- +It is possible to create mock items in HABApp which do not exist in Openhab to create unit tests for rules and libraries. +Ensure that this mechanism is only used for testing because since the items will not exist in openhab they will not get +updated which can lead to hard to track down errors. + +Examples: + +Add an openhab mock item to the item registry + +.. execute_code:: + :hide_output: + + import HABApp + from HABApp.openhab.items import SwitchItem + + item = SwitchItem('my_switch', 'ON') + HABApp.core.Items.add_item(item) + +Remove the mock item from the registry + +.. execute_code:: + :hide_output: + + # hide + import HABApp + from HABApp.openhab.items import SwitchItem + HABApp.core.Items.add_item(SwitchItem('my_switch', 'ON')) + # hide + + + HABApp.core.Items.pop_item('my_switch') + +Note that there are some item methods that encapsulate communication with openhab +(e.g.: ``SwitchItem.on(), SwithItem.off(), and DimmerItem.percentage()``) +These currently do not work with the mock items. The state has to be changed like +any internal item. + +.. execute_code:: + :hide_output: + + import HABApp + from HABApp.openhab.items import SwitchItem + from HABApp.openhab.definitions import OnOffValue + + item = SwitchItem('my_switch', 'ON') + HABApp.core.Items.add_item(item) + + item.set_value(OnOffValue.ON) # without bus event + item.post_value(OnOffValue.OFF) # with bus event diff --git a/_doc/getting_started.rst b/_doc/getting_started.rst index b4d733f9..d665aa58 100644 --- a/_doc/getting_started.rst +++ b/_doc/getting_started.rst @@ -82,8 +82,23 @@ This often comes in handy if there is some logic that shall be applied to differ Interacting with items ------------------------------ -Iterating with items is done through the corresponding Item factory methods. -Posting values will automatically create the events on the event bus. +HABApp uses an internal item registry to store both openhab items and locally +created items (only visible within HABApp). Upon start-up HABApp retrieves +a list of openhab items and adds them to the internal registry. +Rules and HABApp derived libraries may add additional local items which can be used +to share states across rules and/or files. + +An item is created and added to the item registry through the corresponding class factory method + +.. execute_code:: + :hide_output: + + from HABApp.core.items import Item + + # This will create an item in the local (HABApp) item registry + item = Item.get_create_item("an-item-name", "a value") + +Posting values from the item will automatically create the events on the event bus. This example will create an item in HABApp (locally) and post some updates to it. To access items from openhab use the correct openhab item type (see :ref:`the openhab item description `). @@ -137,7 +152,6 @@ To access items from openhab use the correct openhab item type (see :ref:`the op # hide - Watch items for events ------------------------------ It is possible to watch items for changes or updates. @@ -215,8 +229,11 @@ Trigger an event when an item is constant # This will create an event if the item is 10 secs constant watcher = self.my_item.watch_change(10) - # use .EVENT to always listen to the correct event - self.listen_event(self.my_item, self.item_constant, watcher.EVENT) + # this will automatically listen to the correct event + watcher.listen_event(self.item_constant) + + # To listen to all ItemNoChangeEvent/ItemNoUpdateEvent independent of the timeout time use + # self.listen_event(self.my_item, self.item_constant, watcher.EVENT) def item_constant(self, event: ItemNoChangeEvent): print(f'{event}') diff --git a/_doc/interface_openhab.rst b/_doc/interface_openhab.rst index 58db4426..da60889e 100644 --- a/_doc/interface_openhab.rst +++ b/_doc/interface_openhab.rst @@ -35,8 +35,8 @@ Example: # hide import HABApp from HABApp.openhab.items import ContactItem, SwitchItem - HABApp.core.Items.set_item(ContactItem('MyContact', initial_value='OPEN')) - HABApp.core.Items.set_item(SwitchItem('MySwitch', initial_value='OFF')) + HABApp.core.Items.add_item(ContactItem('MyContact', initial_value='OPEN')) + HABApp.core.Items.add_item(SwitchItem('MySwitch', initial_value='OFF')) # hide from HABApp.openhab.items import ContactItem, SwitchItem @@ -552,7 +552,7 @@ for 60 seconds. runner = SimpleRuleRunner() runner.set_up() thing_item = HABApp.openhab.items.Thing('my:thing:uid') - HABApp.core.Items.set_item(thing_item) + HABApp.core.Items.add_item(thing_item) # hide from HABApp import Rule from HABApp.core.events import ItemNoChangeEvent diff --git a/_doc/logging.rst b/_doc/logging.rst index 2e588808..f0c61066 100644 --- a/_doc/logging.rst +++ b/_doc/logging.rst @@ -62,7 +62,7 @@ but the format should be pretty straight forward. - HABApp_default # This logger does log with the default handler propagate: False - MyRule: + MyRule: # Name of the logger, see example usage level: DEBUG handlers: - MyRuleHandler # This logger uses the MyRuleHandler diff --git a/_doc/util.rst b/_doc/util.rst index f33ea100..382321d0 100644 --- a/_doc/util.rst +++ b/_doc/util.rst @@ -209,7 +209,7 @@ The SwitchItemMode is same as ValueMode but enabled/disabled of the mode is cont runner.set_up() from HABApp.openhab.items import SwitchItem - HABApp.core.Items.set_item(SwitchItem('Automatic_Enabled', initial_value='ON')) + HABApp.core.Items.add_item(SwitchItem('Automatic_Enabled', initial_value='ON')) # hide import HABApp diff --git a/conf_testing/lib/HABAppTests/test_base.py b/conf_testing/lib/HABAppTests/test_base.py index 6f2cb367..245ca36e 100644 --- a/conf_testing/lib/HABAppTests/test_base.py +++ b/conf_testing/lib/HABAppTests/test_base.py @@ -3,6 +3,7 @@ import typing import HABApp +from HABApp.core.events.habapp_events import HABAppError from ._rest_patcher import RestPatcher log = logging.getLogger('HABApp.Tests') @@ -32,6 +33,7 @@ def __repr__(self): class TestConfig: def __init__(self): self.skip_on_failure = False + self.warning_is_error = False RULE_CTR = 0 @@ -68,6 +70,23 @@ def __init__(self): # we have to chain the rules later, because we register the rules only once we loaded successfully. self.run_in(2, self.__execute_run) + # collect warnings and infos + self.listen_event(HABApp.core.const.topics.WARNINGS, self.__warning) + self.listen_event(HABApp.core.const.topics.ERRORS, self.__error) + self.__warnings = 0 + self.__errors = 0 + + def __warning(self, event: str): + self.__warnings += 1 + for line in event.splitlines(): + log.warning(line) + + def __error(self, event): + self.__errors += 1 + msg = event.to_str() if isinstance(event, HABAppError) else event + for line in msg.splitlines(): + log.error(line) + def __execute_run(self): with LOCK: if self.__id != RULE_CTR: @@ -130,6 +149,9 @@ def __run_test(self, name: str, data: tuple, result: TestResult): result.run += 1 + self.__warnings = 0 + self.__errors = 0 + # add possibility to skip on failure if self.config.skip_on_failure: if result.nio: @@ -152,6 +174,12 @@ def __run_test(self, name: str, data: tuple, result: TestResult): if msg is True or msg is None: msg = '' + + if self.__errors: + msg = f'{", " if msg else ""}{self.__errors} error{"s" if self.__errors != 1 else ""} in worker' + if self.config.warning_is_error and self.__warnings: + msg = f'{", " if msg else ""}{self.__errors} warning{"s" if self.__errors != 1 else ""} in worker' + if msg == '': result.io += 1 log.info(f'Test {result.run:{width}}/{test_count} "{name}" successful!') diff --git a/conf_testing/rules/bench_rule.py b/conf_testing/rules/bench_rule.py index e9fb7705..f2961a29 100644 --- a/conf_testing/rules/bench_rule.py +++ b/conf_testing/rules/bench_rule.py @@ -8,8 +8,8 @@ from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent from HABApp.openhab.items import NumberItem -WAIT_PREPARE = 5 -RUN_EVERY = 5 +WAIT_PREPARE = 5 * 60 +RUN_EVERY = 2 * 60 class OpenhabBenchRule(HABApp.Rule): @@ -36,7 +36,7 @@ def prepare_bench(self): for k in self.item_list: self.openhab.create_item('String', k) - self.run_every(None, datetime.timedelta(minutes=RUN_EVERY), self.bench_start) + self.run_every(None, datetime.timedelta(seconds=RUN_EVERY), self.bench_start) self.listen_event(self.item_list[-1], self.bench_stop, ValueChangeEvent) dur = time.time() - start diff --git a/conf_testing/rules/test_habapp.py b/conf_testing/rules/test_habapp.py index 2f82a758..bfc7a652 100644 --- a/conf_testing/rules/test_habapp.py +++ b/conf_testing/rules/test_habapp.py @@ -19,27 +19,41 @@ def check_event(self, event: ItemNoUpdateEvent): assert abs(dur) < 0.05, f'Time wrong: {abs(dur):.2f}' def item_events(self, changes=False, secs=5, values=[]): + item_name = get_random_name() self.secs = secs - self.watch_item = Item.get_create_item(get_random_name()) - (self.watch_item.watch_change if changes else self.watch_item.watch_update)(secs) + self.watch_item = Item.get_create_item(item_name) + watcher = (self.watch_item.watch_change if changes else self.watch_item.watch_update)(secs) event = ItemNoUpdateEvent if not changes else ItemNoChangeEvent - - self.ts_set = 0 listener = self.listen_event(self.watch_item, self.check_event, event) - for step, value in enumerate(values): - if step: - time.sleep(0.2) - self.ts_set = time.time() - self.watch_item.set_value(value) - with EventWaiter(self.watch_item.name, event, secs + 2, check_value=False) as w: - w.wait_for_event(value) - if not w.events_ok: - listener.cancel() - return w.events_ok + def _run(): + self.ts_set = 0 + for step, value in enumerate(values): + if step: + time.sleep(0.2) + self.ts_set = time.time() + self.watch_item.set_value(value) + with EventWaiter(self.watch_item.name, event, secs + 2, check_value=False) as w: + w.wait_for_event(value) + if not w.events_ok: + listener.cancel() + return w.events_ok + return True + + if not _run(): + return False + + HABApp.core.Items.pop_item(item_name) + assert not HABApp.core.Items.item_exists(item_name) + time.sleep(1) + self.watch_item = Item.get_create_item(item_name) + + if not _run(): + return False listener.cancel() + watcher.cancel() return True diff --git a/conf_testing/rules/test_openhab_interface.py b/conf_testing/rules/test_openhab_interface.py index df3657b2..7ee4c1f5 100644 --- a/conf_testing/rules/test_openhab_interface.py +++ b/conf_testing/rules/test_openhab_interface.py @@ -16,6 +16,7 @@ def __init__(self): self.add_test('Interface item create/remove', self.test_item_create_delete) self.add_test('Interface group create/remove', self.test_item_create_delete_group) self.add_test('Interface get item definition', self.test_item_definition) + self.add_test('Interface change type', self.test_item_change_type) # test the states for oh_type in get_openhab_test_types(): @@ -41,6 +42,28 @@ def test_item_create_delete(self): self.openhab.remove_item(test_item) assert not self.openhab.item_exists(test_item) + def test_item_change_type(self): + test_item = ''.join(random.choice(string.ascii_letters) for _ in range(20)) + assert not self.openhab.item_exists(test_item) + + self.openhab.create_item('String', test_item) + assert self.openhab.item_exists(test_item) + + # change item type to number and ensure HABApp picks up correctly on the new type + self.openhab.create_item('Number', test_item) + + end = time.time() + 2 + while True: + time.sleep(0.01) + if time.time() > end: + HABApp.openhab.items.NumberItem.get_item(test_item) + break + + if isinstance(HABApp.core.Items.get_item(test_item), HABApp.openhab.items.NumberItem): + break + + self.openhab.remove_item(test_item) + def test_item_create_delete_group(self): test_item = ''.join(random.choice(string.ascii_letters) for _ in range(20)) test_group = ''.join(random.choice(string.ascii_letters) for _ in range(20)) diff --git a/requirements.txt b/requirements.txt index e040b864..b20c5fb9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,9 +8,9 @@ pydantic==1.7.3 pytz==2020.4 stackprinter==0.2.5 tzlocal==2.1 -voluptuous==0.12.0 -watchdog==0.10.4 +voluptuous==0.12.1 +watchdog==1.0.1 ujson==4.0.1 # Backports -dataclasses==0.7;python_version<"3.7" +dataclasses==0.8;python_version<"3.7" diff --git a/tests/conftest.py b/tests/conftest.py index 1f2b3a63..c18c7d54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,13 +1,18 @@ -from .helpers import parent_rule, params -import HABApp -import pytest, asyncio +import functools import typing -import functools +import asyncio +import pytest + +import HABApp +from .helpers import params, parent_rule, sync_worker, event_bus + if typing.TYPE_CHECKING: parent_rule = parent_rule params = params + sync_worker = sync_worker + event_bus = event_bus def raise_err(func): diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 2569499c..ac7af5d8 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1,3 +1,4 @@ -from .sync_worker import SyncWorker +from .sync_worker import sync_worker from .parent_rule import parent_rule -from .parameters import params \ No newline at end of file +from .parameters import params +from .event_bus import event_bus, TmpEventBus \ No newline at end of file diff --git a/tests/helpers/event_bus.py b/tests/helpers/event_bus.py new file mode 100644 index 00000000..623da661 --- /dev/null +++ b/tests/helpers/event_bus.py @@ -0,0 +1,30 @@ + +import pytest +import typing + +from HABApp.core import EventBus, EventBusListener +from HABApp.core import WrappedFunction + + +class TmpEventBus: + def __init__(self): + self.listener: typing.List[EventBusListener] = [] + + def listen_events(self, name: str, cb): + listener = EventBusListener(name, WrappedFunction(cb, name=f'TestFunc for {name}')) + self.listener.append(listener) + EventBus.add_listener(listener) + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + for listener in self.listener: + listener.cancel() + return False # do not suppress exception + + +@pytest.fixture(scope="function") +def event_bus(): + with TmpEventBus() as tb: + yield tb diff --git a/tests/helpers/parameters.py b/tests/helpers/parameters.py index 8276d184..16d979b5 100644 --- a/tests/helpers/parameters.py +++ b/tests/helpers/parameters.py @@ -1,3 +1,4 @@ +import time from pathlib import Path import pytest @@ -14,14 +15,17 @@ class directories: original = HABApp.CONFIG HABApp.CONFIG = DummyCfg - # Parameters.ParameterFileWatcher.UNITTEST = True - # Parameters.setup(None, None) + yield Parameters + + # Clean parameters so they are empty for the next test Parameters._PARAMETERS.clear() # delete possible created files - for f in DummyCfg.directories.param.iterdir(): - if f.name.endswith('.yml'): + to_delete = list(filter(lambda _f: _f.name.endswith('.yml'), DummyCfg.directories.param.iterdir())) + if to_delete: + time.sleep(0.1) + for f in to_delete: f.unlink() HABApp.CONFIG = original diff --git a/tests/helpers/parent_rule.py b/tests/helpers/parent_rule.py index 40ea9f4f..8da901a9 100644 --- a/tests/helpers/parent_rule.py +++ b/tests/helpers/parent_rule.py @@ -7,14 +7,21 @@ class DummyRule: def __init__(self): self.rule_name = 'DummyRule' + self.__dict__['_Rule__event_listener'] = [] + def register_cancel_obj(self, obj): pass + # copied funcs + _get_cb_name = HABApp.Rule._get_cb_name + _add_event_listener = HABApp.Rule._add_event_listener + @fixture def parent_rule(monkeypatch): rule = DummyRule() - # beide imports + + # patch both imports imports monkeypatch.setattr(HABApp.rule, 'get_parent_rule', lambda: rule, raising=True) monkeypatch.setattr(HABApp.rule.rule, 'get_parent_rule', lambda: rule, raising=True) diff --git a/tests/helpers/sync_worker.py b/tests/helpers/sync_worker.py index 492b3a24..332407d8 100644 --- a/tests/helpers/sync_worker.py +++ b/tests/helpers/sync_worker.py @@ -1,40 +1,13 @@ -import typing +import pytest -from HABApp.core import EventBus, EventBusListener from HABApp.core import WrappedFunction -class SyncWorker: - def __init__(self): - self.worker = WrappedFunction._WORKERS - WrappedFunction._WORKERS = self - - self.listener: typing.List[EventBusListener] = [] - - def listen_events(self, name: str, cb): - listener = EventBusListener(name, WrappedFunction(cb, name=f'TestFunc for {name}')) - self.listener.append(listener) - EventBus.add_listener(listener) - +class SyncTestWorker: def submit(self, callback, *args, **kwargs): - # submit never raises and exception, so we don't do it here, too - try: - callback(*args, **kwargs) - except Exception as e: # noqa: F841 - pass - - def remove(self): - WrappedFunction._WORKERS = self.worker - self.worker = None - for listener in self.listener: - listener.cancel() - - def __enter__(self): - assert self.worker is not None - return self + callback(*args, **kwargs) - def __exit__(self, exc_type, exc_val, exc_tb): - self.remove() - # do not supress exception - return False +@pytest.fixture(scope="function") +def sync_worker(monkeypatch): + monkeypatch.setattr(WrappedFunction, '_WORKERS', SyncTestWorker()) diff --git a/tests/test_core/test_all_items.py b/tests/test_core/test_all_items.py index a2ee678b..f0031215 100644 --- a/tests/test_core/test_all_items.py +++ b/tests/test_core/test_all_items.py @@ -18,7 +18,7 @@ def test_item(self): NAME = 'test' created_item = Item(NAME) - Items.set_item(created_item) + Items.add_item(created_item) self.assertTrue(Items.item_exists(NAME)) self.assertIs(created_item, Items.get_item(NAME)) diff --git a/tests/test_core/test_event_bus.py b/tests/test_core/test_event_bus.py index a7236b93..9dea9ed0 100644 --- a/tests/test_core/test_event_bus.py +++ b/tests/test_core/test_event_bus.py @@ -1,56 +1,71 @@ -from pytest import fixture from unittest.mock import MagicMock +from pytest import fixture + from HABApp.core import EventBus, EventBusListener, wrappedfunction -from HABApp.core.items import Item from HABApp.core.events import ComplexEventValue, ValueChangeEvent, ValueUpdateEvent -from ..helpers import SyncWorker +from HABApp.core.items import Item -class TestEvent(): +class TestEvent: pass @fixture -def event_bus(): +def clean_event_bus(): EventBus.remove_all_listeners() yield EventBus EventBus.remove_all_listeners() -def test_str_event(event_bus: EventBus): +def test_repr(clean_event_bus: EventBus, sync_worker): + f = wrappedfunction.WrappedFunction(lambda x: x) + + listener = EventBusListener('test_name', f) + assert listener.desc() == '"test_name" (type AllEvents)' + + listener = EventBusListener('test_name', f, prop_name1='test1', prop_value1='value1') + assert listener.desc() == '"test_name" (type AllEvents, test1==value1)' + + listener = EventBusListener('test_name', f, prop_name2='test2', prop_value2='value2') + assert listener.desc() == '"test_name" (type AllEvents, test2==value2)' + + listener = EventBusListener('test_name', f, prop_name1='test1', prop_value1='value1', + prop_name2='test2', prop_value2='value2') + assert listener.desc() == '"test_name" (type AllEvents, test1==value1, test2==value2)' + + +def test_str_event(clean_event_bus: EventBus, sync_worker): event_history = [] - def set(event): + def append_event(event): event_history.append(event) + func = wrappedfunction.WrappedFunction(append_event) - listener = EventBusListener('str_test', wrappedfunction.WrappedFunction(set)) + listener = EventBusListener('str_test', func) EventBus.add_listener(listener) - with SyncWorker(): - EventBus.post_event('str_test', 'str_event') - + EventBus.post_event('str_test', 'str_event') assert event_history == ['str_event'] -def test_multiple_events(event_bus: EventBus): +def test_multiple_events(clean_event_bus: EventBus, sync_worker): event_history = [] target = ['str_event', TestEvent(), 'str_event2'] - def set(event): + def append_event(event): event_history.append(event) - listener = EventBusListener('test', wrappedfunction.WrappedFunction(set), (str, TestEvent)) + listener = EventBusListener('test', wrappedfunction.WrappedFunction(append_event), (str, TestEvent)) EventBus.add_listener(listener) - with SyncWorker(): - for k in target: - EventBus.post_event('test', k) + for k in target: + EventBus.post_event('test', k) assert event_history == target -def test_complex_event_unpack(event_bus: EventBus): +def test_complex_event_unpack(clean_event_bus: EventBus, sync_worker): """Test that the ComplexEventValue get properly unpacked""" m = MagicMock() assert not m.called @@ -59,9 +74,8 @@ def test_complex_event_unpack(event_bus: EventBus): listener = EventBusListener(item.name, wrappedfunction.WrappedFunction(m, name='test')) EventBus.add_listener(listener) - with SyncWorker(): - item.post_value(ComplexEventValue('ValOld')) - item.post_value(ComplexEventValue('ValNew')) + item.post_value(ComplexEventValue('ValOld')) + item.post_value(ComplexEventValue('ValNew')) # assert that we have been called with exactly one arg for k in m.call_args_list: @@ -79,3 +93,49 @@ def test_complex_event_unpack(event_bus: EventBus): # Events for second post_value assert vars(arg2) == vars(ValueUpdateEvent(item.name, 'ValNew')) assert vars(arg3) == vars(ValueChangeEvent(item.name, 'ValNew', 'ValOld')) + + +def test_event_filter_single(clean_event_bus: EventBus, sync_worker): + events_all, events_filtered1, events_filtered2 = [], [], [] + + def append_all(event): + events_all.append(event) + + def append_filter1(event): + events_filtered1.append(event) + + def append_filter2(event): + events_filtered2.append(event) + + name = 'test_filter' + func1 = wrappedfunction.WrappedFunction(append_filter1) + func2 = wrappedfunction.WrappedFunction(append_filter2) + + # listener to all events + EventBus.add_listener( + EventBusListener(name, wrappedfunction.WrappedFunction(append_all)) + ) + + listener = EventBusListener(name, func1, ValueUpdateEvent, 'value', 'test_value') + EventBus.add_listener(listener) + listener = EventBusListener(name, func2, ValueUpdateEvent, None, None, 'value', 1) + EventBus.add_listener(listener) + + event0 = ValueUpdateEvent(name, None) + event1 = ValueUpdateEvent(name, 'test_value') + event2 = ValueUpdateEvent(name, 1) + + EventBus.post_event(name, event0) + EventBus.post_event(name, event1) + EventBus.post_event(name, event2) + + assert len(events_all) == 3 + assert vars(events_all[0]) == vars(event0) + assert vars(events_all[1]) == vars(event1) + assert vars(events_all[2]) == vars(event2) + + assert len(events_filtered1) == 1 + assert vars(events_filtered1[0]) == vars(event1) + + assert len(events_filtered2) == 1 + assert vars(events_filtered2[0]) == vars(event2) diff --git a/tests/test_core/test_files/test_file_dependencies.py b/tests/test_core/test_files/test_file_dependencies.py index 010ac769..9bd0e1bb 100644 --- a/tests/test_core/test_files/test_file_dependencies.py +++ b/tests/test_core/test_files/test_file_dependencies.py @@ -1,10 +1,12 @@ import logging from pathlib import Path +import pytest + import HABApp from HABApp.core.files.all import file_load_ok, process from HABApp.core.files.file import FileProperties, HABAppFile -from ...helpers import SyncWorker +from ...helpers import TmpEventBus FILE_PROPS = {} @@ -30,12 +32,17 @@ def __repr__(self): return f'' -def test_reload_on(monkeypatch): +@pytest.fixture +def cfg(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/')) + yield + + +def test_reload_on(cfg, sync_worker, event_bus: TmpEventBus): order = [] def process_event(event): @@ -46,36 +53,30 @@ def process_event(event): 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')]) + event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) - assert order == ['params/param1', 'params/param2', 'params/param1'] - order.clear() + process([MockFile('param2'), MockFile('param1')]) - process([]) - assert order == [] + assert order == ['params/param1', 'params/param2', 'params/param1'] + order.clear() - process([MockFile('param2')]) - assert order == ['params/param2', 'params/param1'] - order.clear() + process([]) + assert order == [] - process([MockFile('param1')]) - assert order == ['params/param1'] - order.clear() + process([MockFile('param2')]) + assert order == ['params/param2', 'params/param1'] + order.clear() - 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/')) +def test_reload_dep(cfg, sync_worker, event_bus: TmpEventBus): order = [] def process_event(event): @@ -86,36 +87,30 @@ def process_event(event): 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) + event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) - process([MockFile('param2'), MockFile('param1')]) + process([MockFile('param2'), MockFile('param1')]) - assert order == ['params/param2', 'params/param1'] - order.clear() + assert order == ['params/param2', 'params/param1'] + order.clear() - process([]) - assert order == [] + process([]) + assert order == [] - process([MockFile('param2')]) - assert order == ['params/param2', 'params/param1'] - order.clear() + process([MockFile('param2')]) + assert order == ['params/param2', 'params/param1'] + order.clear() - process([MockFile('param1')]) - assert order == ['params/param1'] - order.clear() + process([MockFile('param1')]) + assert order == ['params/param1'] + order.clear() - process([MockFile('param2')]) - assert order == ['params/param2', '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/')) - +def test_missing_dependencies(cfg, sync_worker, event_bus: TmpEventBus, caplog): order = [] def process_event(event): @@ -126,35 +121,29 @@ def process_event(event): 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')]) + event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) - assert order == ['params/param3'] - order.clear() + process([MockFile('param1'), MockFile('param2'), MockFile('param3')]) - process([]) - assert order == [] + assert order == ['params/param3'] + order.clear() - 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" - ) + process([]) + assert order == [] - assert msg1 in caplog.record_tuples - assert msg2 in caplog.record_tuples + 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/')) +def test_missing_loads(cfg, sync_worker, event_bus: TmpEventBus, caplog): order = [] def process_event(event): @@ -164,22 +153,46 @@ def process_event(event): 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) + event_bus.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 + + +def test_load_continue_after_missing(cfg, sync_worker, event_bus: TmpEventBus, caplog): + order = [] + + def process_event(event): + order.append(event.name) + file_load_ok(event.name) + + FILE_PROPS.clear() + FILE_PROPS['params/p1'] = FileProperties(depends_on=['params/p2'], reloads_on=[]) + FILE_PROPS['params/p2'] = FileProperties() - process([MockFile('param1'), MockFile('param2')]) + event_bus.listen_events(HABApp.core.const.topics.FILES, process_event) - assert order == ['params/param1', 'params/param2'] - order.clear() + process([MockFile('p1')]) - process([]) - assert order == [] + # File can not be loaded + 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") + # Add missing file + process([MockFile('p2')]) - assert msg1 in caplog.record_tuples - assert msg2 in caplog.record_tuples + # Both files get loaded + assert order == ['params/p2', 'params/p1'] diff --git a/tests/test_core/test_files/test_file_properties.py b/tests/test_core/test_files/test_file_properties.py index 32ec92da..aac44824 100644 --- a/tests/test_core/test_files/test_file_properties.py +++ b/tests/test_core/test_files/test_file_properties.py @@ -3,19 +3,32 @@ import pytest +def test_prop_case(): + _in = """# habapp: +# depends on: +# - my_Param.yml +# reloads on: +# - my_File.py +# - other_file.py +""" + p = get_props(_in) + assert p.depends_on == ['my_Param.yml'] + assert p.reloads_on == ['my_File.py', 'other_file.py'] + + def test_prop_1(): _in = """# HABApp: # depends on: -# - my_param.yml +# - my_Param.yml # # reloads on: -# - my_file.py +# - 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'] + assert p.depends_on == ['my_Param.yml'] + assert p.reloads_on == ['my_File.py'] def test_prop_2(): diff --git a/tests/test_core/test_item_watch.py b/tests/test_core/test_item_watch.py index c1ec6223..e8f3e311 100644 --- a/tests/test_core/test_item_watch.py +++ b/tests/test_core/test_item_watch.py @@ -1,8 +1,16 @@ +import asyncio +from unittest.mock import MagicMock + +import pytest + +from HABApp.core.events import ItemNoUpdateEvent from HABApp.core.items import Item from tests.helpers.parent_rule import DummyRule +from ..helpers import TmpEventBus -def test_multiple_add(parent_rule: DummyRule): +@pytest.mark.asyncio +async def test_multiple_add(parent_rule: DummyRule): i = Item('test') w1 = i.watch_change(5) @@ -10,6 +18,28 @@ def test_multiple_add(parent_rule: DummyRule): assert w1 is w2 - w1._fut.cancel() + w1.fut.cancel() w2 = i.watch_change(5) assert w1 is not w2 + + +@pytest.mark.asyncio +async def test_watch(parent_rule: DummyRule, event_bus: TmpEventBus, sync_worker): + + cb = MagicMock() + cb.__name__ = 'MockName' + + secs = 0.2 + + i = Item('test') + i.watch_update(secs / 2) + w = i.watch_update(secs) + w.listen_event(cb) + + i.post_value(1) + await asyncio.sleep(0.3) + + cb.assert_called_once() + assert isinstance(cb.call_args[0][0], ItemNoUpdateEvent) + assert cb.call_args[0][0].name == 'test' + assert cb.call_args[0][0].seconds == secs diff --git a/tests/test_core/test_items/test_item_color.py b/tests/test_core/test_items/test_item_color.py index 51fa0e6f..965bea2d 100644 --- a/tests/test_core/test_items/test_item_color.py +++ b/tests/test_core/test_items/test_item_color.py @@ -4,7 +4,7 @@ from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent from HABApp.core.items import ColorItem -from tests.helpers import SyncWorker +from ...helpers import TmpEventBus def test_repr(): @@ -79,24 +79,23 @@ def test_hsv_to_rgb(): assert i.get_rgb() == (168, 122, 94) -def test_post_update(): - with SyncWorker() as w: - i = ColorItem('test', 23, 44, 66) +def test_post_update(sync_worker, event_bus: TmpEventBus): + i = ColorItem('test', 23, 44, 66) - mock = MagicMock() - w.listen_events(i.name, mock) - mock.assert_not_called() + mock = MagicMock() + event_bus.listen_events(i.name, mock) + mock.assert_not_called() - i.post_value(1, 2, 3) - mock.assert_called() + i.post_value(1, 2, 3) + mock.assert_called() - update = mock.call_args_list[0][0][0] - assert isinstance(update, ValueUpdateEvent) - assert update.name == 'test' - assert update.value == (1, 2, 3) + update = mock.call_args_list[0][0][0] + assert isinstance(update, ValueUpdateEvent) + assert update.name == 'test' + assert update.value == (1, 2, 3) - update = mock.call_args_list[1][0][0] - assert isinstance(update, ValueChangeEvent) - assert update.name == 'test' - assert update.value == (1, 2, 3) - assert update.old_value == (23, 44, 66) + update = mock.call_args_list[1][0][0] + assert isinstance(update, ValueChangeEvent) + assert update.name == 'test' + assert update.value == (1, 2, 3) + assert update.old_value == (23, 44, 66) diff --git a/tests/test_core/test_items/test_item_interface.py b/tests/test_core/test_items/test_item_interface.py new file mode 100644 index 00000000..e15beb9d --- /dev/null +++ b/tests/test_core/test_items/test_item_interface.py @@ -0,0 +1,36 @@ +import pytest + +from HABApp.core import Items +from HABApp.core.items import Item + + +@pytest.fixture +def clean_reg(): + Items._ALL_ITEMS.clear() + yield + Items._ALL_ITEMS.clear() + + +def test_pop(clean_reg): + Items.add_item(Item('test')) + assert Items.item_exists('test') + + with pytest.raises(Items.ItemNotFoundException): + Items.pop_item('asdfadsf') + + Items.pop_item('test') + assert not Items.item_exists('test') + + +def test_add(clean_reg): + added = Item('test') + Items.add_item(added) + assert Items.item_exists('test') + + # adding the same item multiple times will not cause an exception + Items.add_item(added) + Items.add_item(added) + + # adding a new item -> exception + with pytest.raises(Items.ItemAlreadyExistsError): + Items.add_item(Item('test')) diff --git a/tests/test_core/test_items/test_item_times.py b/tests/test_core/test_items/test_item_times.py index c3ede5bb..ef89531d 100644 --- a/tests/test_core/test_items/test_item_times.py +++ b/tests/test_core/test_items/test_item_times.py @@ -1,12 +1,14 @@ -from datetime import datetime +import asyncio +from datetime import datetime, timedelta from unittest.mock import MagicMock -import asyncio import pytest import pytz import HABApp +import HABApp.core.items.tmp_data from HABApp.core.items.base_item import ChangedTime, UpdatedTime +from ...helpers import TmpEventBus @pytest.fixture(scope="function") @@ -35,6 +37,24 @@ def c(): w2.cancel() +def test_sec_timedelta(): + a = UpdatedTime('test', datetime.now(tz=pytz.utc)) + w1 = a.add_watch(1) + + # We return the same object because it is the same time + assert w1 is a.add_watch(timedelta(seconds=1)) + + w2 = a.add_watch(timedelta(seconds=3)) + assert w2.fut.secs == 3 + + w3 = a.add_watch(timedelta(minutes=3)) + assert w3.fut.secs == 3 * 60 + + w1.cancel() + w2.cancel() + w3.cancel() + + @pytest.mark.asyncio async def test_rem(u: UpdatedTime): for t in u.tasks: @@ -49,8 +69,8 @@ async def test_cancel_running(u: UpdatedTime): w2 = u.tasks[1] await asyncio.sleep(1.1) - assert w1._fut.task.done() - assert not w2._fut.task.done() + assert w1.fut.task.done() + assert not w2.fut.task.done() assert w2 in u.tasks w2.cancel() @@ -118,3 +138,73 @@ async def test_event_change(c: ChangedTime): assert c.seconds == 3 list.cancel() + + +@pytest.mark.asyncio +async def test_watcher_change_restore(parent_rule): + name = 'test_save_restore' + + item_a = HABApp.core.items.Item(name) + HABApp.core.Items.add_item(item_a) + watcher = item_a.watch_change(1) + + # remove item + assert name not in HABApp.core.items.tmp_data.TMP_DATA + HABApp.core.Items.pop_item(name) + assert name in HABApp.core.items.tmp_data.TMP_DATA + + item_b = HABApp.core.items.Item(name) + HABApp.core.Items.add_item(item_b) + + assert item_b._last_change.tasks == [watcher] + HABApp.core.Items.pop_item(name) + + +@pytest.mark.asyncio +async def test_watcher_update_restore(parent_rule): + name = 'test_save_restore' + + item_a = HABApp.core.items.Item(name) + HABApp.core.Items.add_item(item_a) + watcher = item_a.watch_update(1) + + # remove item + assert name not in HABApp.core.items.tmp_data.TMP_DATA + HABApp.core.Items.pop_item(name) + assert name in HABApp.core.items.tmp_data.TMP_DATA + + item_b = HABApp.core.items.Item(name) + HABApp.core.Items.add_item(item_b) + + assert item_b._last_update.tasks == [watcher] + HABApp.core.Items.pop_item(name) + + +@pytest.mark.asyncio +async def test_watcher_update_cleanup(monkeypatch, parent_rule, c: ChangedTime, sync_worker, event_bus: TmpEventBus): + monkeypatch.setattr(HABApp.core.items.tmp_data.CLEANUP, 'secs', 0.7) + + text_warning = '' + + def get_log(event): + nonlocal text_warning + text_warning = event + + event_bus.listen_events(HABApp.core.const.topics.WARNINGS, get_log) + + name = 'test_save_restore' + item_a = HABApp.core.items.Item(name) + HABApp.core.Items.add_item(item_a) + item_a.watch_update(1) + + # remove item + assert name not in HABApp.core.items.tmp_data.TMP_DATA + HABApp.core.Items.pop_item(name) + assert name in HABApp.core.items.tmp_data.TMP_DATA + + # ensure that the tmp data gets deleted + await asyncio.sleep(0.8) + assert name not in HABApp.core.items.tmp_data.TMP_DATA + + assert text_warning == 'Item test_save_restore has been deleted 0.7s ago even though it has item watchers.' \ + ' If it will be added again the watchers have to be created again, too!' diff --git a/tests/test_core/test_wrapped_func.py b/tests/test_core/test_wrapped_func.py index 6c8bbe50..8d2c5bd0 100644 --- a/tests/test_core/test_wrapped_func.py +++ b/tests/test_core/test_wrapped_func.py @@ -1,24 +1,19 @@ import asyncio -import re +import sys import typing import unittest from unittest.mock import MagicMock -import HABApp import pytest -from asynctest import CoroutineMock +import HABApp from HABApp.core import WrappedFunction from HABApp.core.const.topics import ERRORS as TOPIC_ERRORS - -class FileNameRemover(str): - REGEX = re.compile(r'^\s+File ".+?$', re.MULTILINE) - - def __eq__(self, other): - a = FileNameRemover.REGEX.sub('', self) - b = FileNameRemover.REGEX.sub('', other) - return a == b +if sys.version_info < (3, 8): + from mock import AsyncMock +else: + from unittest.mock import AsyncMock class TestCases(unittest.TestCase): @@ -96,7 +91,7 @@ def tmp(): @pytest.mark.asyncio async def test_async_run(): - coro = CoroutineMock() + coro = AsyncMock() WrappedFunction._EVENT_LOOP = asyncio.get_event_loop() f = WrappedFunction(coro, name='coro_mock') f.run() @@ -106,7 +101,7 @@ async def test_async_run(): @pytest.mark.asyncio async def test_async_args(): - coro = CoroutineMock() + coro = AsyncMock() WrappedFunction._EVENT_LOOP = asyncio.get_event_loop() f = WrappedFunction(coro, name='coro_mock') f.run('arg1', 'arg2', kw1='kw1') @@ -122,7 +117,7 @@ async def tmp(): f = WrappedFunction(tmp) WrappedFunction._EVENT_LOOP = asyncio.get_event_loop() - err_func = CoroutineMock() + err_func = AsyncMock() err_listener = HABApp.core.EventBusListener(TOPIC_ERRORS, WrappedFunction(err_func, name='ErrMock')) HABApp.core.EventBus.add_listener(err_listener) diff --git a/tests/test_core/test_wrapper.py b/tests/test_core/test_wrapper.py index 91fa88ff..d6bc2031 100644 --- a/tests/test_core/test_wrapper.py +++ b/tests/test_core/test_wrapper.py @@ -1,6 +1,8 @@ +import asyncio import logging from unittest.mock import MagicMock +import aiohttp import pytest import HABApp @@ -52,3 +54,22 @@ def func_a(_l): def test_func_wrapper(p_mock): func_a(['asdf', 'asdf']) + + +@pytest.mark.skip(reason="Behavior still unclear") +def test_exception_format(p_mock): + async def test(): + async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(0.01)) as session: + async with session.get('http://localhost:12345'): + pass + + with ExceptionToHABApp(log): + asyncio.get_event_loop().run_until_complete(test()) + + tb = p_mock.call_args[0][1].traceback + + # verbose asyncio + assert 'self = ' not in tb + + # verbose aiohttp + assert 'async def __aenter__(self) -> _RetType:' not in tb diff --git a/tests/test_openhab/test_items/test_thing.py b/tests/test_openhab/test_items/test_thing.py index 50263c3d..eae7a165 100644 --- a/tests/test_openhab/test_items/test_thing.py +++ b/tests/test_openhab/test_items/test_thing.py @@ -10,7 +10,7 @@ @pytest.fixture(scope="function") def test_thing(): thing = HABApp.openhab.items.Thing('test_thing') - HABApp.core.Items.set_item(thing) + HABApp.core.Items.add_item(thing) yield thing diff --git a/tox.ini b/tox.ini index 03fd66fe..575e5e42 100644 --- a/tox.ini +++ b/tox.ini @@ -19,7 +19,7 @@ python = deps = pytest pytest-asyncio - asynctest + mock;python_version<"3.8" -r{toxinidir}/requirements.txt commands =