From 2a4836a835b7d3d62df93164ec9559d50162d813 Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Mon, 19 Oct 2020 16:22:24 +0200 Subject: [PATCH] 0.15.4 - Reworked items file creation, made the file nicer and fixed a crash (#fixes 171) - Changing an item through the rest api new gets properly reflected in HABApp (#fixes 170) - Added some tests and modified the build pipeline --- .flake8 | 12 +- .travis.yml | 22 ++- Dockerfile | 2 +- HABApp/__version__.py | 2 +- .../plugin_things/item_worker.py | 81 ----------- .../plugin_things/items_file.py | 134 ++++++++++++++++++ .../plugin_things/plugin_things.py | 3 +- HABApp/openhab/events/item_events.py | 2 +- conf_testing/rules/test_habapp_internals.py | 27 +++- tests/__init__.py | 1 - tests/conftest.py | 1 + tests/context.py | 27 ---- tests/helpers/__init__.py | 3 +- tests/helpers/parent_rule.py | 24 ++++ tests/test_core/test_item_watch.py | 10 +- .../test_events/test_from_dict.py | 2 +- .../test_thing/test_file_writer.py | 47 ++++++ tests/test_utils/test_multivalue.py | 13 +- tox.ini | 1 + 19 files changed, 277 insertions(+), 137 deletions(-) create mode 100644 HABApp/openhab/connection_logic/plugin_things/items_file.py create mode 100644 tests/conftest.py delete mode 100644 tests/context.py create mode 100644 tests/helpers/parent_rule.py create mode 100644 tests/test_openhab/test_plugins/test_thing/test_file_writer.py diff --git a/.flake8 b/.flake8 index c349f755..fa5a54d3 100644 --- a/.flake8 +++ b/.flake8 @@ -6,10 +6,12 @@ ignore = E203, # E203 whitespace before ':' E221, + # E241 multiple spaces after ',' + E241, # E251 unexpected spaces around keyword / parameter equals - E251 + E251, # E303 too many blank lines - E303 + E303, max-line-length = 120 exclude = @@ -20,8 +22,8 @@ exclude = dist, conf, __init__.py, - tests/context.py + tests/conftest.py, # the interfaces will throw unused imports - HABApp/openhab/interface.py - HABApp/openhab/interface_async.py + HABApp/openhab/interface.py, + HABApp/openhab/interface_async.py, diff --git a/.travis.yml b/.travis.yml index 8bcd2555..c550c6c5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,14 @@ language: python os: linux +stages: +- unit tests +- docker +- docs jobs: include: - &python_38 + stage: unit tests python: 3.8 script: tox install: pip install tox @@ -17,14 +22,22 @@ jobs: python: 3.7 env: TOXENV=py37 +# Travis does not support +# - <<: *python_38 +# python: 3.9 +# env: TOXENV=py39 + - <<: *python_38 + stage: docs env: TOXENV=flake - <<: *python_38 - python: 3.7 + stage: docs + python: 3.8 env: TOXENV=docs - - stage: docker + - &docker + stage: docker language: shell install: # test docker build @@ -36,6 +49,11 @@ jobs: # output stdout to travis in case we can not start the container - docker logs habapp # test if container is still running + # -q means quiet and will return 0 if a match is found - docker ps | grep -q habapp # Show logs from HABApp - docker exec habapp tail -n +1 /config/log/HABApp.log + + # Docker arm build (e.g. raspberry pi) + - <<: *docker + arch: arm64 diff --git a/Dockerfile b/Dockerfile index 4da02129..0ee2646e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.7-alpine +FROM python:3.8-alpine VOLUME [ "/config"] diff --git a/HABApp/__version__.py b/HABApp/__version__.py index 44ff7ad9..5e6db2a2 100644 --- a/HABApp/__version__.py +++ b/HABApp/__version__.py @@ -1 +1 @@ -__version__ = '0.15.3' +__version__ = '0.15.4' diff --git a/HABApp/openhab/connection_logic/plugin_things/item_worker.py b/HABApp/openhab/connection_logic/plugin_things/item_worker.py index 19d1af8a..3bff2228 100644 --- a/HABApp/openhab/connection_logic/plugin_things/item_worker.py +++ b/HABApp/openhab/connection_logic/plugin_things/item_worker.py @@ -1,4 +1,3 @@ -from pathlib import Path from typing import Set, Dict import HABApp @@ -143,83 +142,3 @@ async def create_item(item: UserItem, test: bool) -> bool: await async_set_habapp_metadata(name, habapp_data) return True - - -def create_items_file(path: Path, items: Dict[str, UserItem]): - - field_fmt = { - 'type': '{}', - 'name': '{}', - 'label': '"{}"', - 'icon': '<{}>', - 'groups': '({})', - 'tags': '[{}]', - 'bracket_open': '', - 'link': 'channel = "{}"', - 'metadata': '', - 'bracket_close': '', - } - - values = [] - for item in items.values(): - new = {} - for k, format in field_fmt.items(): - if k in ('bracket_open', 'metadata', 'bracket_close'): - continue - val = item.__dict__[k] - if isinstance(val, list): - val = ', '.join(val) - - new[k] = format.format(val) if val else '' - - if item.link or item.metadata: - new['bracket_open'] = '{' - new['bracket_close'] = '}' - - if item.metadata: - __m = [] - for k, __meta in item.metadata.items(): - __val = __meta['value'] - __cfg = __meta['config'] - - _str = f'{k}={__val}' if not isinstance(__val, str) else f'{k}="{__val}"' - if __cfg: - __conf_strs = [] - for _k, _v in __cfg.items(): - __conf_strs.append(f'{_k}={_v}' if not isinstance(_v, str) else f'{_k}="{_v}"') - _str += f' [{", ".join(__conf_strs)}]' - __m.append(_str) - - # link needs the "," so we indent properly - if item.link: - new['link'] += ', ' - # metadata - new['metadata'] = ', '.join(__m) - - values.append(new) - - # if we have no items we don't create the file - if not values: - return None - - f_dict = {} - for k in field_fmt.keys(): - width = 1 - - if k not in ('bracket_open', 'metadata', 'bracket_close'): - width = max(map(len, map(lambda x: x[k], values)), default=0) - # indent to multiples of 4, if the entries are missing do not indent - if width: - for _ in range(4): - width += 1 - if not width % 4: - break - - # set with to min 1 because format crashes with with=0 - f_dict[f'w_{k}'] = max(1, width) - - fmt_str = ' '.join(f'{{{k}:{{w_{k}}}s}}' for k in field_fmt.keys()) + '\n' - - with path.open(mode='w', encoding='utf-8') as file: - for v in values: - file.write(fmt_str.format(**f_dict, **v)) diff --git a/HABApp/openhab/connection_logic/plugin_things/items_file.py b/HABApp/openhab/connection_logic/plugin_things/items_file.py new file mode 100644 index 00000000..8931300c --- /dev/null +++ b/HABApp/openhab/connection_logic/plugin_things/items_file.py @@ -0,0 +1,134 @@ +from pathlib import Path +from typing import Dict, List +import re + +from .cfg_validator import UserItem + + +RE_GROUP_NAMES = re.compile(r'([A-Za-z0-9-]+?)(?=[A-Z_ -])') + + +def _get_item_val_dict(field_fmt: Dict[str, str], item: UserItem): + new = {} + for k, format in field_fmt.items(): + if k in ('bracket_open', 'metadata', 'bracket_close'): + continue + val = item.__dict__[k] + if isinstance(val, list): + val = ', '.join(val) + + new[k] = format.format(val) if val else '' + + if item.link or item.metadata: + new['bracket_open'] = '{' + new['bracket_close'] = '}' + else: + new['bracket_open'] = '' + new['bracket_close'] = '' + + if item.metadata: + __m = [] + for k, __meta in item.metadata.items(): + __val = __meta['value'] + __cfg = __meta['config'] + + _str = f'{k}={__val}' if not isinstance(__val, str) else f'{k}="{__val}"' + if __cfg: + __conf_strs = [] + for _k, _v in __cfg.items(): + __conf_strs.append(f'{_k}={_v}' if not isinstance(_v, str) else f'{_k}="{_v}"') + _str += f' [{", ".join(__conf_strs)}]' + __m.append(_str) + + # link needs the "," so we indent properly + if item.link: + new['link'] += ',' + # metadata + new['metadata'] = ', '.join(__m) + else: + new['metadata'] = '' + + return new + + +def _get_fmt_str(field_fmt: Dict[str, str], vals: List[Dict[str, str]]) -> str: + w_dict = {} + for k in field_fmt.keys(): + # + # w_dict[k] = 0 + # continue + + width = max(map(len, map(lambda x: x[k], vals)), default=0) + # indent to multiples of 4, if the entries are missing do not indent + if width and k not in ('bracket_open', 'bracket_close', 'metadata'): + add = width % 4 + if not add: + add = 4 + width += add + w_dict[k] = width + + ret = '' + for k in field_fmt.keys(): + w = w_dict[k] + if not w: + ret += f'{{{k}:s}}' # format crashes with with=0 so this is a different format string + continue + else: + ret += f'{{{k}:{w}s}}' + return ret + + +def create_items_file(path: Path, items_dict: Dict[str, UserItem]): + # if we don't have any items we don't create an empty file + if not items_dict: + return None + + field_fmt = { + 'type': '{}', + 'name': '{}', + 'label': '"{}"', + 'icon': '<{}>', + 'groups': '({})', + 'tags': '[{}]', + 'bracket_open': '', + 'link': 'channel = "{}"', + 'metadata': '{}', + 'bracket_close': '', + } + + grouped_items = {None: []} + for _name, _item in sorted(items_dict.items()): + m = RE_GROUP_NAMES.match(_name) + grp = grouped_items.setdefault(m.group(1) if m is not None else None, []) + grp.append(_get_item_val_dict(field_fmt, _item)) + + # aggregate single entry items to a block + _aggr = [] + for _name, _items in grouped_items.items(): + if len(_items) <= 1: + _aggr.append(_name) + for _name in _aggr: + grouped_items[None].extend(grouped_items[_name]) + grouped_items.pop(_name) + + # single entry items get created at the end of file + if None in grouped_items: + grouped_items[None] = grouped_items.pop(None) + + lines = [] + for _name, _item_vals in grouped_items.items(): + # skip empty items + if not _item_vals: + continue + + fmt = _get_fmt_str(field_fmt, _item_vals) + + for _val in _item_vals: + _l = fmt.format(**_val) + lines.append(_l.strip() + '\n') + + # newline aber each name block + lines.append('\n') + + with path.open(mode='w', encoding='utf-8') as file: + file.writelines(lines) diff --git a/HABApp/openhab/connection_logic/plugin_things/plugin_things.py b/HABApp/openhab/connection_logic/plugin_things/plugin_things.py index 4d1ea783..8b160c54 100644 --- a/HABApp/openhab/connection_logic/plugin_things/plugin_things.py +++ b/HABApp/openhab/connection_logic/plugin_things/plugin_things.py @@ -10,7 +10,8 @@ from HABApp.openhab.connection_logic.plugin_things.filters import THING_ALIAS, CHANNEL_ALIAS from HABApp.openhab.connection_logic.plugin_things.filters import apply_filters, log_overview from ._log import log -from .item_worker import create_item, cleanup_items, create_items_file +from .item_worker import create_item, cleanup_items +from .items_file import create_items_file from .thing_worker import update_thing_cfg from .._plugin import OnConnectPlugin diff --git a/HABApp/openhab/events/item_events.py b/HABApp/openhab/events/item_events.py index 58fb2064..2f45e273 100644 --- a/HABApp/openhab/events/item_events.py +++ b/HABApp/openhab/events/item_events.py @@ -90,7 +90,7 @@ def from_dict(cls, topic: str, payload: dict): # 'payload': '[{"type":"Switch","name":"Test","tags":[],"groupNames":[]}, # {"type":"Contact","name":"Test","tags":[],"groupNames":[]}]', # 'type': 'ItemUpdatedEvent' - return cls(topic[16:-8], payload[1]['type']) + return cls(topic[16:-8], payload[0]['type']) def __repr__(self): return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}>' diff --git a/conf_testing/rules/test_habapp_internals.py b/conf_testing/rules/test_habapp_internals.py index f9418c57..44563e0e 100644 --- a/conf_testing/rules/test_habapp_internals.py +++ b/conf_testing/rules/test_habapp_internals.py @@ -1,7 +1,10 @@ from HABApp.openhab.connection_handler.func_async import async_get_item_with_habapp_meta, async_set_habapp_metadata, \ async_remove_habapp_metadata from HABApp.openhab.definitions.rest.habapp_data import HABAppThingPluginData -from HABAppTests import TestBaseRule, OpenhabTmpItem, run_coro +from HABApp.openhab.events import ItemUpdatedEvent +from HABApp.openhab.interface import create_item +from HABApp.openhab.items import StringItem, NumberItem, DatetimeItem +from HABAppTests import TestBaseRule, OpenhabTmpItem, run_coro, EventWaiter class TestMetadata(TestBaseRule): @@ -42,3 +45,25 @@ def create_meta(self): TestMetadata() + + +class ChangeItemType(TestBaseRule): + + def __init__(self): + super().__init__() + self.add_test('change_item', self.change_item) + + def change_item(self): + with OpenhabTmpItem(None, 'Number') as tmpitem: + NumberItem.get_item(tmpitem.name) + + create_item('String', tmpitem.name) + EventWaiter(tmpitem.name, ItemUpdatedEvent(tmpitem.name, 'String'), 2, False) + StringItem.get_item(tmpitem.name) + + create_item('DateTime', tmpitem.name) + EventWaiter(tmpitem.name, ItemUpdatedEvent(tmpitem.name, 'DateTime'), 2, False) + DatetimeItem.get_item(tmpitem.name) + + +ChangeItemType() diff --git a/tests/__init__.py b/tests/__init__.py index 50379f6c..15a967ae 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1,2 +1 @@ from .rule_runner import SimpleRuleRunner -from .context import add_stdout, HABApp diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..bae7a9df --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1 @@ +from .helpers import parent_rule \ No newline at end of file diff --git a/tests/context.py b/tests/context.py deleted file mode 100644 index a4223dfc..00000000 --- a/tests/context.py +++ /dev/null @@ -1,27 +0,0 @@ -import os, logging -import sys -__path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..')) -if __path not in sys.path: - sys.path.insert(0, __path) - -import HABApp - - -def add_stdout(logger_name=None): - _log = logging.getLogger(logger_name) - ch = logging.StreamHandler(sys.stdout) - ch.setLevel(logging.DEBUG) - ch.setFormatter(logging.Formatter("[{asctime:s}] [{name:25s}] {levelname:8s} | {message:s}", style='{')) - _log.addHandler(ch) - - -if __name__ == "__main__": - import unittest - - add_stdout() - - testSuite = unittest.defaultTestLoader.discover( - start_dir=os.path.abspath(os.path.join(os.path.dirname(__file__))), - top_level_dir=os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) - - text_runner = unittest.TextTestRunner().run(testSuite) \ No newline at end of file diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py index 26350ce8..e3b17fe5 100644 --- a/tests/helpers/__init__.py +++ b/tests/helpers/__init__.py @@ -1 +1,2 @@ -from .sync_worker import SyncWorker \ No newline at end of file +from .sync_worker import SyncWorker +from .parent_rule import parent_rule \ No newline at end of file diff --git a/tests/helpers/parent_rule.py b/tests/helpers/parent_rule.py new file mode 100644 index 00000000..40ea9f4f --- /dev/null +++ b/tests/helpers/parent_rule.py @@ -0,0 +1,24 @@ +from pytest import fixture + +import HABApp + + +class DummyRule: + def __init__(self): + self.rule_name = 'DummyRule' + + def register_cancel_obj(self, obj): + pass + + +@fixture +def parent_rule(monkeypatch): + rule = DummyRule() + # beide imports + monkeypatch.setattr(HABApp.rule, 'get_parent_rule', lambda: rule, raising=True) + monkeypatch.setattr(HABApp.rule.rule, 'get_parent_rule', lambda: rule, raising=True) + + # util imports + monkeypatch.setattr(HABApp.util.multimode.item, 'get_parent_rule', lambda: rule, raising=True) + + yield rule diff --git a/tests/test_core/test_item_watch.py b/tests/test_core/test_item_watch.py index eb5d24ac..c1ec6223 100644 --- a/tests/test_core/test_item_watch.py +++ b/tests/test_core/test_item_watch.py @@ -1,14 +1,8 @@ -import HABApp from HABApp.core.items import Item +from tests.helpers.parent_rule import DummyRule -class DummyRule: - def register_cancel_obj(self, obj): - pass - - -def test_multiple_add(monkeypatch): - monkeypatch.setattr(HABApp.rule, 'get_parent_rule', lambda: DummyRule(), raising=True) +def test_multiple_add(parent_rule: DummyRule): i = Item('test') w1 = i.watch_change(5) diff --git a/tests/test_openhab/test_events/test_from_dict.py b/tests/test_openhab/test_events/test_from_dict.py index 78cb5643..5b02e19a 100644 --- a/tests/test_openhab/test_events/test_from_dict.py +++ b/tests/test_openhab/test_events/test_from_dict.py @@ -51,7 +51,7 @@ def test_ItemUpdatedEvent(): }) assert isinstance(event, ItemUpdatedEvent) assert event.name == 'NameUpdated' - assert event.type == 'Contact' + assert event.type == 'Switch' def test_ItemStateChangedEvent1(): diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer.py new file mode 100644 index 00000000..d6047973 --- /dev/null +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer.py @@ -0,0 +1,47 @@ +# flake8: noqa +import io + +from HABApp.openhab.connection_logic.plugin_things.items_file import create_items_file, UserItem + +class MyStringIO(io.StringIO): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.text = None + + def open(self, *args, **kwargs): + return self + + def close(self, *args, **kwargs): + self.text = self.getvalue() + super().close(*args, **kwargs) + + +def test_creation(tmp_path_factory): + + meta_auto = {'auto_update': {'value': 'False', 'config': {}}} + + objs = [ + UserItem('String', 'Test_zwave_o_1', '', '', [], [], 'zwave:link:device', {}), + UserItem('String', 'Test_zwave_o_2', '', '', [], [], 'zwave:link:device1', metadata=meta_auto), + UserItem('String', 'Test_zwave_no', '', '', [], ['tag1'], '', {}), + UserItem('String', 'Test_zwave_all', 'label1', 'icon1', ['grp1'], ['tag1', 'tag2'], '', {}), + + UserItem('String', 'NewName', '', '', [], [], '', metadata=meta_auto), + UserItem('String', 'NewName1', '', '', [], [], '', metadata=meta_auto), + ] + + t = MyStringIO() + create_items_file(t, {k.name: k for k in objs}) + + print('\n-' + t.text + '-') + + expected = """String NewName {auto_update="False"} +String NewName1 {auto_update="False"} + +String Test_zwave_all "label1" (grp1) [tag1, tag2] +String Test_zwave_no [tag1] +String Test_zwave_o_1 {channel = "zwave:link:device" } +String Test_zwave_o_2 {channel = "zwave:link:device1", auto_update="False"} + +""" + assert expected == t.text diff --git a/tests/test_utils/test_multivalue.py b/tests/test_utils/test_multivalue.py index 9946908d..3bbe399b 100644 --- a/tests/test_utils/test_multivalue.py +++ b/tests/test_utils/test_multivalue.py @@ -2,6 +2,7 @@ from HABApp.util.multimode import BaseMode, ValueMode, MultiModeItem from ..test_core import ItemTests +from tests.helpers.parent_rule import DummyRule class TestMultiModeItem(ItemTests): @@ -9,7 +10,7 @@ class TestMultiModeItem(ItemTests): TEST_VALUES = [0, 'str', (1, 2, 3)] -def test_diff_prio(): +def test_diff_prio(parent_rule: DummyRule): p = MultiModeItem('TestItem') p1 = ValueMode('modea', '1234') p2 = ValueMode('modeb', '4567') @@ -29,7 +30,7 @@ def test_diff_prio(): assert p.value == 8888 -def test_calculate_lower_priority_value(): +def test_calculate_lower_priority_value(parent_rule: DummyRule): p = MultiModeItem('TestItem') m1 = ValueMode('modea', '1234') m2 = ValueMode('modeb', '4567') @@ -42,7 +43,7 @@ def test_calculate_lower_priority_value(): assert m2.calculate_lower_priority_value() == 'asdf' -def test_auto_disable_1(): +def test_auto_disable_1(parent_rule: DummyRule): p = MultiModeItem('TestItem') m1 = ValueMode('modea', 50) m2 = ValueMode('modeb', 60, auto_disable_func= lambda l, o: l > o) @@ -59,7 +60,7 @@ def test_auto_disable_1(): assert p.value == 59 -def test_auto_disable_func(): +def test_auto_disable_func(parent_rule: DummyRule): p = MultiModeItem('TestItem') m1 = ValueMode('modea', 50) m2 = ValueMode('modeb', 60, auto_disable_func=lambda low, s: low == 40) @@ -79,7 +80,7 @@ def test_auto_disable_func(): assert m2.enabled is False -def test_unknown(): +def test_unknown(parent_rule: DummyRule): p = MultiModeItem('asdf') with pytest.raises(KeyError): p.get_mode('asdf') @@ -89,7 +90,7 @@ def test_unknown(): p.get_mode('asdf') -def test_remove(): +def test_remove(parent_rule: DummyRule): p = MultiModeItem('asdf') m1 = BaseMode('m1') m2 = BaseMode('m2') diff --git a/tox.ini b/tox.ini index 9fd9d4e1..dab08162 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,7 @@ envlist = py36 py37 py38 + py39 flake docs