diff --git a/HABApp/__version__.py b/HABApp/__version__.py index bb67aeb6..56abcc4b 100644 --- a/HABApp/__version__.py +++ b/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '0.15.1' +__version__ = '0.15.2' diff --git a/HABApp/config/config.py b/HABApp/config/config.py index 67a8253c..55004d80 100644 --- a/HABApp/config/config.py +++ b/HABApp/config/config.py @@ -28,7 +28,6 @@ def on_all_values_set(self): if not self.config.is_dir(): log.info(f'Manual thing configuration disabled! Folder {self.config} does not exist!') - # add path for libraries if self.lib.is_dir(): lib_path = str(self.lib) diff --git a/HABApp/core/EventBus.py b/HABApp/core/EventBus.py index f28e69e8..0f186eaa 100644 --- a/HABApp/core/EventBus.py +++ b/HABApp/core/EventBus.py @@ -4,7 +4,7 @@ from HABApp.core.wrapper import log_exception from . import EventBusListener -from .events import ComplexEventValue +from .events import ComplexEventValue, ValueChangeEvent _event_log = logging.getLogger('HABApp.EventBus') _habapp_log = logging.getLogger('HABApp') @@ -34,6 +34,8 @@ def post_event(topic: str, event): try: if isinstance(event.value, ComplexEventValue): event.value = event.value.value + if isinstance(event, ValueChangeEvent) and isinstance(event.old_value, ComplexEventValue): + event.old_value = event.old_value.value except AttributeError: pass diff --git a/HABApp/core/const/__init__.py b/HABApp/core/const/__init__.py index 150c5324..a10374fe 100644 --- a/HABApp/core/const/__init__.py +++ b/HABApp/core/const/__init__.py @@ -2,6 +2,3 @@ from . import topics from .const import MISSING from .loop import loop - -# utilities last! -from . import utilities diff --git a/HABApp/core/const/utilities/__init__.py b/HABApp/core/const/utilities/__init__.py deleted file mode 100644 index c447d7e7..00000000 --- a/HABApp/core/const/utilities/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .pending_future import PendingFuture \ No newline at end of file diff --git a/HABApp/core/items/base_item_watch.py b/HABApp/core/items/base_item_watch.py index 631440b4..8d17f628 100644 --- a/HABApp/core/items/base_item_watch.py +++ b/HABApp/core/items/base_item_watch.py @@ -3,7 +3,7 @@ import HABApp from ..const import loop -from ..const.utilities import PendingFuture +from HABApp.core.lib import PendingFuture from ..events import ItemNoChangeEvent, ItemNoUpdateEvent diff --git a/HABApp/core/lib/__init__.py b/HABApp/core/lib/__init__.py index 22700393..615e5aa1 100644 --- a/HABApp/core/lib/__init__.py +++ b/HABApp/core/lib/__init__.py @@ -1 +1,2 @@ from .funcs import list_files +from .pending_future import PendingFuture diff --git a/HABApp/core/const/utilities/pending_future.py b/HABApp/core/lib/pending_future.py similarity index 92% rename from HABApp/core/const/utilities/pending_future.py rename to HABApp/core/lib/pending_future.py index e7be62ea..2a927210 100644 --- a/HABApp/core/const/utilities/pending_future.py +++ b/HABApp/core/lib/pending_future.py @@ -1,7 +1,7 @@ import asyncio import typing -from asyncio import Task, sleep, ensure_future -from typing import Optional, Awaitable, Callable, Any +from asyncio import Task, ensure_future, sleep +from typing import Any, Awaitable, Callable, Optional class PendingFuture: diff --git a/HABApp/core/logger.py b/HABApp/core/logger.py index 6cfa3261..644bb03b 100644 --- a/HABApp/core/logger.py +++ b/HABApp/core/logger.py @@ -1,27 +1,29 @@ import logging import HABApp -from .const import topics +from .const.topics import ERRORS as _T_ERRORS +from .const.topics import WARNINGS as _T_WARNINGS +from .const.topics import INFOS as _T_INFOS def log_error(logger: logging.Logger, text: str): logger.error(text) HABApp.core.EventBus.post_event( - topics.ERRORS, text + _T_ERRORS, text ) def log_warning(logger: logging.Logger, text: str): logger.warning(text) HABApp.core.EventBus.post_event( - topics.WARNINGS, text + _T_WARNINGS, text ) def log_info(logger: logging.Logger, text: str): logger.info(text) HABApp.core.EventBus.post_event( - topics.INFOS, text + _T_INFOS, text ) @@ -65,14 +67,14 @@ def __bool__(self): class HABAppError(HABAppLogger): _LEVEL = logging.ERROR - _TOPIC = topics.ERRORS + _TOPIC = _T_ERRORS class HABAppWarning(HABAppLogger): _LEVEL = logging.WARNING - _TOPIC = topics.WARNINGS + _TOPIC = _T_WARNINGS class HABAppInfo(HABAppLogger): _LEVEL = logging.INFO - _TOPIC = topics.INFOS + _TOPIC = _T_INFOS diff --git a/HABApp/openhab/connection_handler/func_async.py b/HABApp/openhab/connection_handler/func_async.py index 3ff617d4..bb4d0176 100644 --- a/HABApp/openhab/connection_handler/func_async.py +++ b/HABApp/openhab/connection_handler/func_async.py @@ -221,7 +221,6 @@ async def async_get_channel_links() -> List[Dict[str, str]]: return await ret.json(encoding='utf-8') - async def async_get_channel_link(channel_uid: str, item_name: str) -> ItemChannelLinkDefinition: ret = await get(__get_link_url(channel_uid, item_name), log_404=False) if ret.status == 404: @@ -242,7 +241,7 @@ async def async_create_channel_link(channel_uid: str, item_name: str, configurat if not await async_item_exists(item_name): raise ItemNotFoundError.from_name(item_name) - ret = await put(__get_link_url(channel_uid, item_name), json=configuration) + ret = await put(__get_link_url(channel_uid, item_name), json={'configuration': configuration}) if ret is None: return False return ret.status == 200 diff --git a/HABApp/openhab/connection_handler/func_sync.py b/HABApp/openhab/connection_handler/func_sync.py index e3a4c03d..17670a18 100644 --- a/HABApp/openhab/connection_handler/func_sync.py +++ b/HABApp/openhab/connection_handler/func_sync.py @@ -246,9 +246,11 @@ def get_channel_link(channel_uid: str, item_name: str) -> ItemChannelLinkDefinit def create_channel_link(channel_uid: str, item_name: str, configuration: dict = {}) -> bool: - """ creates a link between a (things) channel and an item + """creates a link between a (things) channel and an item - :param link_def: an instance of ItemChannelLinkDefinition with at least channel_uid and item_name set + :param channel_uid: uid of the (thing) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) + :param item_name: name of the item + :param configuration: optional configuration for the channel :return: true on successful creation, otherwise false """ @@ -266,7 +268,7 @@ def create_channel_link(channel_uid: str, item_name: str, configuration: dict = def remove_channel_link(channel_uid: str, item_name: str) -> bool: """ removes a link between a (things) channel and an item - :param channel_uid: uid of the (things) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) + :param channel_uid: uid of the (thing) channel (usually something like AAAA:BBBBB:CCCCC:DDDD:0#SOME_NAME) :param item_name: name of the item :return: true on successful removal, otherwise false """ diff --git a/HABApp/openhab/connection_logic/plugin_things/plugin_things.py b/HABApp/openhab/connection_logic/plugin_things/plugin_things.py index e07cec2e..4d1ea783 100644 --- a/HABApp/openhab/connection_logic/plugin_things/plugin_things.py +++ b/HABApp/openhab/connection_logic/plugin_things/plugin_things.py @@ -3,7 +3,7 @@ from typing import Dict, Set import HABApp -from HABApp.core.const.utilities import PendingFuture +from HABApp.core.lib import PendingFuture from HABApp.core.logger import log_warning, HABAppError from HABApp.openhab.connection_handler.func_async import async_get_things from HABApp.openhab.connection_logic.plugin_things.cfg_validator import validate_cfg, InvalidItemNameError diff --git a/HABApp/openhab/items/__init__.py b/HABApp/openhab/items/__init__.py index a1a3bd02..f8a8ceb8 100644 --- a/HABApp/openhab/items/__init__.py +++ b/HABApp/openhab/items/__init__.py @@ -6,7 +6,7 @@ from .color_item import ColorItem from .number_item import NumberItem from .datetime_item import DatetimeItem -from .group_item import GroupItem from .string_item import StringItem, LocationItem, PlayerItem +from .image_item import ImageItem +from .group_item import GroupItem from .thing_item import Thing -from .image_item import ImageItem \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 00000000..1aba38f6 --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1 @@ +include LICENSE diff --git a/_doc/util.rst b/_doc/util.rst index 14c1bd89..f33ea100 100644 --- a/_doc/util.rst +++ b/_doc/util.rst @@ -86,8 +86,8 @@ Basic Example # This shows how to enable/disable a mode and how to get a mode from the item print('disable/enable the higher priority mode') - item.get_mode('manual').set_enabled(False) - item.get_mode('manual').set_value(11) + item.get_mode('manual').set_enabled(False) # disable mode + item.get_mode('manual').set_value(11) # setting a value will enable it again # This shows that changes of the lower priority is only show when # the mode with the higher priority gets disabled diff --git a/conf_testing/lib/HABAppTests/_rest_patcher.py b/conf_testing/lib/HABAppTests/_rest_patcher.py new file mode 100644 index 00000000..e21559f5 --- /dev/null +++ b/conf_testing/lib/HABAppTests/_rest_patcher.py @@ -0,0 +1,77 @@ +import logging +from pprint import pformat + +import HABApp.openhab.connection_handler.http_connection +from HABApp.openhab.connection_handler.http_connection import HTTP_PREFIX + +FUNC_PATH = HABApp.openhab.connection_handler.func_async + + +def shorten_url(url: str): + url = str(url) + if url.startswith(HTTP_PREFIX): + return url[len(HTTP_PREFIX):] + return url + + +class RestPatcher: + def __init__(self, name): + self.log = logging.getLogger('HABApp.Rest') + self.log.debug('') + self.log.debug(f'{name}:') + + def wrap(self, to_call): + async def resp_wrap(*args, **kwargs): + + resp = await to_call(*args, **kwargs) + + out = '' + if kwargs.get('json'): + out = f' {kwargs["json"]}' + if kwargs.get('data'): + out = f' "{kwargs["data"]}"' + self.log.debug(f'{resp.request_info.method:^6s} {shorten_url(resp.request_info.url)} ({resp.status}){out}') + + def wrap_content(content_func): + async def content_func_wrap(*cargs, **ckwargs): + t = await content_func(*cargs, **ckwargs) + + # pretty print the response + obj = pformat(t, indent=2) + if obj[0] == '[' and obj[-1] == ']': + obj = f'[\n {obj[1:-1]}\n]' + elif obj[0] == '{' and obj[-1] == '}': + obj = f'{{\n {obj[1:-1]}\n}}' + lines = obj.splitlines() + if len(lines) <= 1: + self.log.debug(f'{"->":6s}') + else: + for i, l in enumerate(lines): + self.log.debug(f'{"->" if not i else "":^6s} {l}') + + return t + return content_func_wrap + + resp.text = wrap_content(resp.text) + resp.json = wrap_content(resp.json) + return resp + return resp_wrap + + def __enter__(self): + self._get = FUNC_PATH.get + self._put = FUNC_PATH.put + self._post = FUNC_PATH.post + self._delete = FUNC_PATH.delete + + FUNC_PATH.get = self.wrap(self._get) + FUNC_PATH.put = self.wrap(self._put) + FUNC_PATH.post = self.wrap(self._post) + FUNC_PATH.delete = self.wrap(self._delete) + + def __exit__(self, exc_type, exc_val, exc_tb): + FUNC_PATH.get = self._get + FUNC_PATH.put = self._put + FUNC_PATH.post = self._post + FUNC_PATH.delete = self._delete + + return False diff --git a/conf_testing/lib/HABAppTests/test_base.py b/conf_testing/lib/HABAppTests/test_base.py index d0eb97b0..2bbb04f5 100644 --- a/conf_testing/lib/HABAppTests/test_base.py +++ b/conf_testing/lib/HABAppTests/test_base.py @@ -1,9 +1,9 @@ import logging import threading -import traceback import typing import HABApp +from ._rest_patcher import RestPatcher log = logging.getLogger('HABApp.Tests') @@ -15,9 +15,23 @@ def __init__(self): self.run = 0 self.io = 0 self.nio = 0 + self.skipped = 0 + + def __iadd__(self, other): + assert isinstance(other, TestResult) + self.run += other.run + self.io += other.io + self.nio += other.nio + self.skipped += other.skipped + return self def __repr__(self): - return f'Processed {self.run:d} Tests: IO: {self.io} NIO: {self.nio}' + return f'Processed {self.run:d} Tests: IO: {self.io} NIO: {self.nio} skipped: {self.skipped}' + + +class TestConfig: + def __init__(self): + self.skip_on_failure = False RULE_CTR = 0 @@ -49,6 +63,8 @@ def __init__(self): self.__id = get_next_id(self) self.register_on_unload(lambda: pop_rule(self.__id)) + self.config = TestConfig() + # we have to chain the rules later, because we register the rules only once we loaded successfully. self.run_in(2, self.__execute_run) @@ -62,8 +78,11 @@ def __execute_run(self): assert isinstance(rule, TestBaseRule) if rule.tests_started: continue - rule.run_tests(result) + r = TestResult() + rule.run_tests(r) + result += r + log.info('-' * 120) log.info(str(result)) if not result.nio else log.error(str(result)) print(str(result)) return None @@ -85,7 +104,7 @@ def run_tests(self, result: TestResult): self.set_up() except Exception as e: log.error(f'"Set up of {self.__class__.__name__}" failed: {e}') - for line in traceback.format_exc().splitlines(): + for line in HABApp.core.wrapper.format_exception(e): log.error(line) result.nio += 1 return None @@ -93,42 +112,52 @@ def run_tests(self, result: TestResult): test_count = len(self.__tests_funcs) log.info(f'Running {test_count} tests for {self.rule_name}') - width = test_count // 10 + 1 - test_current = 0 for name, test_data in self.__tests_funcs.items(): - test_current += 1 - result.run += 1 - - try: - func = test_data[0] - args = test_data[1] - kwargs = test_data[2] - - msg = func(*args, **kwargs) - if msg is True or msg is None: - msg = '' - except Exception as e: - log.error(f'Test "{name}" failed: {e}') - for line in traceback.format_exc().splitlines(): - log.error(line) - result.nio += 1 - continue - - if msg == '': - result.io += 1 - log.info( f'Test {test_current:{width}}/{test_count} "{name}" successful!') - else: - result.nio += 1 - if isinstance(msg, bool): - log.error(f'Test {test_current:{width}}/{test_count} "{name}" failed') - else: - log.error(f'Test {test_current:{width}}/{test_count} "{name}" failed: {msg} ({type(msg)})') + self.__run_test(name, test_data, result) # TEAR DOWN try: self.tear_down() except Exception as e: log.error(f'"Set up of {self.__class__.__name__}" failed: {e}') - for line in traceback.format_exc().splitlines(): + for line in HABApp.core.wrapper.format_exception(e): + log.error(line) + result.nio += 1 + + def __run_test(self, name: str, data: tuple, result: TestResult): + test_count = len(self.__tests_funcs) + width = test_count // 10 + 1 + + result.run += 1 + + # add possibility to skip on failure + if self.config.skip_on_failure: + if result.nio: + result.skipped += 1 + log.warning(f'Test {result.run:{width}}/{test_count} "{name}" skipped!') + return None + + try: + func = data[0] + args = data[1] + kwargs = data[2] + with RestPatcher(self.__class__.__name__ + '.' + name): + msg = func(*args, **kwargs) + except Exception as e: + log.error(f'Test "{name}" failed: {e}') + for line in HABApp.core.wrapper.format_exception(e): log.error(line) result.nio += 1 + return None + + if msg is True or msg is None: + msg = '' + if msg == '': + result.io += 1 + log.info(f'Test {result.run:{width}}/{test_count} "{name}" successful!') + else: + result.nio += 1 + if isinstance(msg, bool): + log.error(f'Test {result.run:{width}}/{test_count} "{name}" failed') + else: + log.error(f'Test {result.run:{width}}/{test_count} "{name}" failed: {msg} ({type(msg)})') diff --git a/conf_testing/logging.yml b/conf_testing/logging.yml index 11b5b78b..74d23b93 100644 --- a/conf_testing/logging.yml +++ b/conf_testing/logging.yml @@ -34,6 +34,15 @@ handlers: formatter: HABApp_format level: DEBUG + HABApp_rest_file: + class: logging.handlers.RotatingFileHandler + filename: 'test_rest.log' + maxBytes: 10_485_760 + backupCount: 3 + + formatter: HABApp_format + level: DEBUG + BufferEventFile: class: logging.handlers.MemoryHandler capacity: 0 @@ -72,3 +81,9 @@ loggers: handlers: - HABApp_test_file propagate: False + + HABApp.Rest: + level: DEBUG + handlers: + - HABApp_rest_file + propagate: False diff --git a/conf_testing/rules/test_mqtt.py b/conf_testing/rules/test_mqtt.py index d38f9916..39e0b146 100644 --- a/conf_testing/rules/test_mqtt.py +++ b/conf_testing/rules/test_mqtt.py @@ -16,6 +16,8 @@ class TestMQTTEvents(TestBaseRule): def __init__(self): super().__init__() + self.config.skip_on_failure = True + self.mqtt_test_data = ['asdf', 1, 1.1, str({'a': 'b'}), {'key': 'value'}, ['mylist', 'mylistvalue']] self.add_test('MQTT events', self.test_mqtt_events, MqttValueUpdateEvent) diff --git a/conf_testing/rules/test_openhab_event_types.py b/conf_testing/rules/test_openhab_event_types.py index ad6f60de..a2cd81b9 100644 --- a/conf_testing/rules/test_openhab_event_types.py +++ b/conf_testing/rules/test_openhab_event_types.py @@ -38,7 +38,8 @@ def test_events(self, item_type, test_values): def test_quantity_type_events(self, dimension): unit_of_dimension = { - 'Length': 'm', 'Temperature': '°C', 'Pressure': 'hPa', 'Speed': 'km/h', 'Intensity': 'W/m²', 'Angle': '°' + 'Length': 'm', 'Temperature': '°C', 'Pressure': 'hPa', 'Speed': 'km/h', 'Intensity': 'W/m²', 'Angle': '°', + 'Dimensionless': '', } item_name = f'{dimension}_event_test' diff --git a/conf_testing/rules/test_openhab_interface_links.py b/conf_testing/rules/test_openhab_interface_links.py index 3efa2b72..ba7b4786 100644 --- a/conf_testing/rules/test_openhab_interface_links.py +++ b/conf_testing/rules/test_openhab_interface_links.py @@ -1,12 +1,14 @@ from HABApp.core.Items import get_all_items from HABApp.openhab.items import Thing -from conf_testing.lib.HABAppTests import TestBaseRule +from HABAppTests import TestBaseRule class TestOpenhabInterfaceLinks(TestBaseRule): def __init__(self): super().__init__() + self.config.skip_on_failure = True + self.item_name: str = "" self.astro_sun_thing: str = "" self.channel_uid: str = "" @@ -47,20 +49,22 @@ def __find_astro_sun_thing(self) -> str: def test_update_link(self): assert self.oh.create_channel_link(self.channel_uid, self.item_name, {"profile": "system:default"}) + assert self.oh.channel_link_exists(self.channel_uid, self.item_name) new_cfg = {'profile': 'system:offset', 'offset': 7.0} - assert self.oh.create_channel_link(self.channel_uid, self.item_name, new_cfg) - assert self.oh.get_channel_link(self.channel_uid, self.item_name).configuration == new_cfg + channel_link = self.oh.get_channel_link(self.channel_uid, self.item_name) + assert channel_link.configuration == new_cfg def test_get_link(self): - assert self.oh.create_channel_link(self.channel_uid, self.item_name, {"profile": "system:default"}) + target = {"profile": "system:default"} + assert self.oh.create_channel_link(self.channel_uid, self.item_name, target) link = self.oh.get_channel_link(self.channel_uid, self.item_name) assert link.item_name == self.item_name assert link.channel_uid == self.channel_uid - assert link.configuration == {"profile": "system:default"} + assert link.configuration == target def test_remove_link(self): assert self.oh.create_channel_link(self.channel_uid, self.item_name, {"profile": "system:default"}) @@ -69,11 +73,9 @@ def test_remove_link(self): def test_link_existence(self): assert self.oh.create_channel_link(self.channel_uid, self.item_name, {"profile": "system:default"}) - assert self.oh.channel_link_exists(self.channel_uid, self.item_name) assert self.oh.remove_channel_link(self.channel_uid, self.item_name) - assert not self.oh.channel_link_exists(self.channel_uid, self.item_name) def test_create_link(self): diff --git a/requirements.txt b/requirements.txt index d8185850..4a29b96f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,18 +1,18 @@ aiohttp-sse-client==0.1.7 aiohttp==3.6.2 astral==2.2 -bidict==0.19.0 +bidict==0.21.2 easyco==0.2.2 -paho-mqtt==1.5.0 -pydantic==1.5.1 +paho-mqtt==1.5.1 +pydantic==1.6.1 pytz==2020.1 stackprinter==0.2.4 tzlocal==2.1 -voluptuous==0.11.7 +voluptuous==0.12.0 watchdog==0.10.3 # This is optional -ujson==3.0.0 +ujson==3.2.0 # Backports dataclasses==0.7;python_version<"3.7" diff --git a/tests/test_core/test_event_bus.py b/tests/test_core/test_event_bus.py index afc0184a..a7236b93 100644 --- a/tests/test_core/test_event_bus.py +++ b/tests/test_core/test_event_bus.py @@ -1,6 +1,9 @@ from pytest import fixture +from unittest.mock import MagicMock 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 @@ -45,3 +48,34 @@ def set(event): EventBus.post_event('test', k) assert event_history == target + + +def test_complex_event_unpack(event_bus: EventBus): + """Test that the ComplexEventValue get properly unpacked""" + m = MagicMock() + assert not m.called + + item = Item.get_create_item('test_complex') + 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')) + + # assert that we have been called with exactly one arg + for k in m.call_args_list: + assert len(k[0]) == 1 + + arg0 = m.call_args_list[0][0][0] + arg1 = m.call_args_list[1][0][0] + arg2 = m.call_args_list[2][0][0] + arg3 = m.call_args_list[3][0][0] + + # Events for first post_value + assert vars(arg0) == vars(ValueUpdateEvent(item.name, 'ValOld')) + assert vars(arg1) == vars(ValueChangeEvent(item.name, 'ValOld', None)) + + # Events for second post_value + assert vars(arg2) == vars(ValueUpdateEvent(item.name, 'ValNew')) + assert vars(arg3) == vars(ValueChangeEvent(item.name, 'ValNew', 'ValOld')) diff --git a/tests/test_core/test_utilities.py b/tests/test_core/test_utilities.py index 8726ce99..83800e34 100644 --- a/tests/test_core/test_utilities.py +++ b/tests/test_core/test_utilities.py @@ -4,7 +4,7 @@ import pytest import HABApp -from HABApp.core.const.utilities import PendingFuture +from HABApp.core.lib import PendingFuture @pytest.yield_fixture()