diff --git a/.ruff.toml b/.ruff.toml index 83f9d0bc..ecfee7bf 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -111,3 +111,8 @@ lines-after-imports = 2 # https://docs.astral.sh/ruff/settings/#lint_isort_l "interface_*.py" = [ "F401" # F401 [*] {name} imported but unused ] + + +"run/*" = [ + "S101" # Use of assert detected +] diff --git a/run/conf_testing/lib/HABAppTests/__init__.py b/run/conf_testing/lib/HABAppTests/__init__.py index c6138f1e..e70b073f 100644 --- a/run/conf_testing/lib/HABAppTests/__init__.py +++ b/run/conf_testing/lib/HABAppTests/__init__.py @@ -1,8 +1,10 @@ -from .utils import get_random_name, run_coro, find_astro_sun_thing, get_bytes_text +from .test_data import get_openhab_test_events, get_openhab_test_states, get_openhab_test_types +from .utils import find_astro_sun_thing, get_random_name + + +# isort: split -from .test_rule import TestBaseRule, TestResult from .event_waiter import EventWaiter from .item_waiter import ItemWaiter -from .openhab_tmp_item import OpenhabTmpItem - -from .test_data import get_openhab_test_events, get_openhab_test_states, get_openhab_test_types +from .openhab_tmp_item import AsyncOpenhabTmpItem, OpenhabTmpItem +from .test_rule import TestBaseRule, TestResult, TestRunnerRule diff --git a/run/conf_testing/lib/HABAppTests/compare_values.py b/run/conf_testing/lib/HABAppTests/compare_values.py index dc14543d..06cf1f1d 100644 --- a/run/conf_testing/lib/HABAppTests/compare_values.py +++ b/run/conf_testing/lib/HABAppTests/compare_values.py @@ -1,9 +1,16 @@ -from .utils import get_bytes_text +from binascii import b2a_hex -def get_equal_text(value1, value2) -> str: - return f'{get_value_text(value1)} {"==" if value1 == value2 else "!="} {get_value_text(value2)}' +def get_bytes_text(value: object) -> object: + if isinstance(value, bytes) and len(value) > 300: + return b2a_hex(value[:40]).decode() + ' ... ' + b2a_hex(value[-40:]).decode() + return value -def get_value_text(value) -> str: +def get_equal_text(value1: object, value2: object) -> str: + equal = value1 == value2 and isinstance(value1, value2.__class__) + return f'{get_value_text(value1):s} {"==" if equal else "!="} {get_value_text(value2):s}' + + +def get_value_text(value: object) -> str: return f'{get_bytes_text(value)} ({str(type(value))[8:-2]})' diff --git a/run/conf_testing/lib/HABAppTests/errors.py b/run/conf_testing/lib/HABAppTests/errors.py index 9528c5fa..f73e47c9 100644 --- a/run/conf_testing/lib/HABAppTests/errors.py +++ b/run/conf_testing/lib/HABAppTests/errors.py @@ -1,8 +1,3 @@ -class TestCaseFailed(Exception): - def __init__(self, msg: str) -> None: - self.msg = msg - - -class TestCaseWarning(Exception): +class TestCaseFailed(Exception): # noqa: N818 def __init__(self, msg: str) -> None: self.msg = msg diff --git a/run/conf_testing/lib/HABAppTests/event_waiter.py b/run/conf_testing/lib/HABAppTests/event_waiter.py index ee527912..ef192625 100644 --- a/run/conf_testing/lib/HABAppTests/event_waiter.py +++ b/run/conf_testing/lib/HABAppTests/event_waiter.py @@ -1,5 +1,8 @@ +import asyncio import logging import time +from collections.abc import Generator +from time import monotonic from types import TracebackType from typing import Any, TypeVar @@ -18,8 +21,6 @@ log = logging.getLogger('HABApp.Tests') -EVENT_TYPE = TypeVar('EVENT_TYPE') - class EventWaiter: def __init__(self, name: BaseValueItem | str, @@ -29,61 +30,85 @@ def __init__(self, name: BaseValueItem | str, assert isinstance(name, str) assert isinstance(event_filter, EventFilterBase) - self.name = name - self.event_filter = event_filter - self.timeout = timeout + self._name = name + self._event_filter = event_filter + self._timeout = timeout - self.event_listener = EventBusListener( - self.name, + self._event_listener = EventBusListener( + self._name, wrap_func(self.__process_event), - self.event_filter + self._event_filter ) self._received_events = [] def __process_event(self, event) -> None: - if isinstance(self.event_filter, EventFilter): - assert isinstance(event, self.event_filter.event_class) + if isinstance(self._event_filter, EventFilter): + assert isinstance(event, self._event_filter.event_class) self._received_events.append(event) def clear(self) -> None: self._received_events.clear() - def wait_for_event(self, **kwargs) -> EVENT_TYPE: - - start = time.time() - - while True: - time.sleep(0.02) + def _check_wait_event(self, attribs: dict[str, Any]) -> Generator[float, Any, Any]: + start = monotonic() + end = start + self._timeout - if time.time() > start + self.timeout: - expected_values = 'with ' + ', '.join([f'{__k}={__v}' for __k, __v in kwargs.items()]) if kwargs else '' - msg = f'Timeout while waiting for {self.event_filter.describe()} for {self.name} {expected_values}' - raise TestCaseFailed(msg) + while monotonic() < end: + yield 0.01 if not self._received_events: continue event = self._received_events.pop() - if kwargs: - if self.compare_event_value(event, kwargs): + if attribs: + if self.compare_event_value(event, attribs): return event continue return event - raise ValueError() + expected_values = 'with ' + ', '.join([f'{__k}={__v}' for __k, __v in attribs.items()]) if attribs else '' + msg = f'Timeout while waiting for {self._event_filter.describe()} for {self._name} {expected_values}' + raise TestCaseFailed(msg) + + def wait_for_event(self, **kwargs: Any) -> Any: + gen = self._check_wait_event(kwargs) + try: + while True: + delay = next(gen) + time.sleep(delay) + except StopIteration as e: + event = e.value + + if event is None: + raise ValueError() + return event + + async def async_wait_for_event(self, **kwargs: Any) -> Any: + gen = self._check_wait_event(kwargs) + try: + while True: + delay = next(gen) + await asyncio.sleep(delay) + except StopIteration as e: + event = e.value + + if event is None: + raise ValueError() + return event def __enter__(self) -> 'EventWaiter': - get_current_context().add_event_listener(self.event_listener) + get_current_context().add_event_listener(self._event_listener) return self - def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> bool: - get_current_context().remove_event_listener(self.event_listener) + def __exit__(self, exc_type: type[BaseException] | None, + exc_val: BaseException | None, exc_tb: TracebackType | None) -> bool: + get_current_context().remove_event_listener(self._event_listener) @staticmethod - def compare_event_value(event, kwargs: dict[str, Any]): + def compare_event_value(event: Any, kwargs: dict[str, Any]) -> bool: only_value = 'value' in kwargs and len(kwargs) == 1 val_msg = [] diff --git a/run/conf_testing/lib/HABAppTests/item_waiter.py b/run/conf_testing/lib/HABAppTests/item_waiter.py index 4905c6ee..36671a33 100644 --- a/run/conf_testing/lib/HABAppTests/item_waiter.py +++ b/run/conf_testing/lib/HABAppTests/item_waiter.py @@ -1,6 +1,10 @@ +import asyncio import logging import time +from collections.abc import Generator +from time import monotonic from types import TracebackType +from typing import Any, Final from HABApp.core.items import BaseValueItem from HABAppTests.compare_values import get_equal_text @@ -11,38 +15,48 @@ class ItemWaiter: - def __init__(self, item, timeout=1) -> None: - self.item = item - assert isinstance(item, BaseValueItem), f'{item} is not an Item' + def __init__(self, item: str | BaseValueItem, timeout: float = 1) -> None: + self._item: Final = item if not isinstance(item, str) else BaseValueItem.get_item(item) + assert isinstance(self._item, BaseValueItem), f'{self._item} is not an Item' - self.timeout = timeout + self._timeout: Final = timeout - def wait_for_attribs(self, **kwargs) -> bool: - start = time.time() - end = start + self.timeout + def _check_attribs(self, attribs: dict[str, Any]) -> Generator[float, Any, None]: + start = monotonic() + end = start + self._timeout - while True: - time.sleep(0.01) + while monotonic() < end: + yield 0.01 - for name, target in kwargs.items(): - if getattr(self.item, name) != target: + for name, target in attribs.items(): + if getattr(self._item, name) != target: break else: - return True - - if time.time() > end: - indent = max(map(len, kwargs)) - failed = [ - f'{name:>{indent:d}s}: {get_equal_text(getattr(self.item, name), target)}' - for name, target in kwargs.items() - ] - failed_msg = '\n'.join(failed) - msg = f'Timeout waiting for {self.item.name}!\n{failed_msg}' - raise TestCaseFailed(msg) - - def wait_for_state(self, state=None): + return None + + indent = max(map(len, attribs)) + failed = [ + f'{name:>{indent:d}s}: {get_equal_text(getattr(self._item, name), target)}' + for name, target in attribs.items() + ] + failed_msg = '\n'.join(failed) + msg = f'Timeout waiting for {self._item.name}!\n{failed_msg}' + raise TestCaseFailed(msg) + + def wait_for_attribs(self, **kwargs) -> None: + for delay in self._check_attribs(kwargs): + time.sleep(delay) + + async def async_wait_for_attribs(self, **kwargs) -> None: + for delay in self._check_attribs(kwargs): + await asyncio.sleep(delay) + + def wait_for_state(self, state=None) -> None: return self.wait_for_attribs(value=state) + async def async_wait_for_state(self, state=None) -> None: + return await self.async_wait_for_attribs(value=state) + def __enter__(self) -> 'ItemWaiter': return self diff --git a/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py index f8de4515..5fbfb9ad 100644 --- a/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py +++ b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py @@ -1,45 +1,87 @@ +import asyncio import time +from collections.abc import Generator from functools import wraps +from time import monotonic from types import TracebackType +from typing import Any, NotRequired, Self, TypedDict, Unpack import HABApp +from HABApp.core.asyncio import AsyncContextError, thread_context from HABApp.openhab.definitions.topics import TOPIC_ITEMS +from HABApp.openhab.items import OpenhabItem from . import EventWaiter, get_random_name -class OpenhabTmpItem: +class ItemApiKwargs(TypedDict): + label: NotRequired[str] + category: NotRequired[str] + tags: NotRequired[list[str]] + groups: NotRequired[list[str]] + group_type: NotRequired[str] + group_function: NotRequired[str] + group_function_params: NotRequired[list[str]] + + +class OpenhabTmpItemBase: + @classmethod + def _insert_kwargs(cls, item_type: str, name: str | None, kwargs: dict[str, Any], arg_name: str | None) -> Self: + item = cls(item_type, name) + if arg_name is not None: + if arg_name in kwargs: + msg = f'Arg {arg_name} already set!' + raise ValueError(msg) + kwargs[arg_name] = item + return item + + def __init__(self, item_type: str, item_name: str | None = None) -> None: + self._type: str = item_type + self._name = get_random_name(item_type) if item_name is None else item_name + + @property + def name(self) -> str: + return self._name + + def _wait_until_item_exists(self) -> Generator[float, Any, Any]: + # wait max 1 sec for the item to be created + stop = monotonic() + 1.5 + while not HABApp.core.Items.item_exists(self.name): + if monotonic() > stop: + msg = f'Item {self.name} was not found!' + raise TimeoutError(msg) + + yield 0.01 + + +class OpenhabTmpItem(OpenhabTmpItemBase): @staticmethod - def use(item_type: str, name: str | None = None, arg_name: str = 'item'): + def use(item_type: str, name: str | None = None, *, arg_name: str = 'item'): def decorator(func): @wraps(func) def new_func(*args, **kwargs): - assert arg_name not in kwargs, f'arg {arg_name} already set' - item = OpenhabTmpItem(item_type, name) + item = OpenhabTmpItem._insert_kwargs(item_type, name, kwargs, arg_name) try: - kwargs[arg_name] = item return func(*args, **kwargs) finally: item.remove() + return new_func + return decorator @staticmethod - def create(item_type: str, name: str | None = None, arg_name: str | None = None): + def create(item_type: str, name: str | None = None, *, arg_name: str | None = None): def decorator(func): @wraps(func) def new_func(*args, **kwargs): - with OpenhabTmpItem(item_type, name) as f: - if arg_name is not None: - assert arg_name not in kwargs, f'arg {arg_name} already set' - kwargs[arg_name] = f + + with OpenhabTmpItem._insert_kwargs(item_type, name, kwargs, arg_name): return func(*args, **kwargs) + return new_func - return decorator - def __init__(self, item_type: str, item_name: str | None = None) -> None: - self.type: str = item_type - self.name = get_random_name(item_type) if item_name is None else item_name + return decorator def __enter__(self) -> HABApp.openhab.items.OpenhabItem: return self.create_item() @@ -49,39 +91,82 @@ def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException return False def remove(self) -> None: + if thread_context.get(None) is None: + raise AsyncContextError(self.remove) HABApp.openhab.interface_sync.remove_item(self.name) - def _create(self, label='', category='', tags: list[str] = [], groups: list[str] = [], - group_type: str = '', group_function: str = '', - group_function_params: list[str] = []) -> None: + def _create(self, **kwargs: Unpack[ItemApiKwargs]) -> None: + if thread_context.get(None) is None: + raise AsyncContextError(self._create) + interface = HABApp.openhab.interface_sync - interface.create_item(self.type, self.name, label=label, category=category, - tags=tags, groups=groups, group_type=group_type, - group_function=group_function, group_function_params=group_function_params) + interface.create_item(self._type, self._name, **kwargs) - def create_item(self, label='', category='', tags: list[str] = [], groups: list[str] = [], - group_type: str = '', group_function: str = '', - group_function_params: list[str] = []) -> HABApp.openhab.items.OpenhabItem: + def create_item(self, **kwargs: Unpack[ItemApiKwargs]) -> OpenhabItem: - self._create(label=label, category=category, tags=tags, groups=groups, group_type=group_type, - group_function=group_function, group_function_params=group_function_params) + self._create(**kwargs) + for delay in self._wait_until_item_exists(): + time.sleep(delay) + return OpenhabItem.get_item(self.name) - # wait max 1 sec for the item to be created - stop = time.time() + 1 - while not HABApp.core.Items.item_exists(self.name): - time.sleep(0.01) - if time.time() > stop: - msg = f'Item {self.name} was not found!' - raise TimeoutError(msg) + def modify(self, **kwargs: Unpack[ItemApiKwargs]) -> None: + with EventWaiter(TOPIC_ITEMS, HABApp.core.events.EventFilter(HABApp.openhab.events.ItemUpdatedEvent)) as w: + self._create(**kwargs) + w.wait_for_event() - return HABApp.openhab.items.OpenhabItem.get_item(self.name) - def modify(self, label='', category='', tags: list[str] = [], groups: list[str] = [], - group_type: str = '', group_function: str = '', group_function_params: list[str] = []) -> None: +class AsyncOpenhabTmpItem(OpenhabTmpItemBase): + @staticmethod + def use(item_type: str, name: str | None = None, *, arg_name: str = 'item'): + def decorator(func): + @wraps(func) + async def new_func(*args, **kwargs): + item = AsyncOpenhabTmpItem._insert_kwargs(item_type, name, kwargs, arg_name) + try: + return await func(*args, **kwargs) + finally: + await item.remove() - with EventWaiter(TOPIC_ITEMS, HABApp.core.events.EventFilter(HABApp.openhab.events.ItemUpdatedEvent)) as w: + return new_func - self._create(label=label, category=category, tags=tags, groups=groups, group_type=group_type, - group_function=group_function, group_function_params=group_function_params) + return decorator - w.wait_for_event() + @staticmethod + def create(item_type: str, name: str | None = None, *, arg_name: str | None = None): + + def decorator(func): + @wraps(func) + async def new_func(*args, **kwargs): + + async with AsyncOpenhabTmpItem._insert_kwargs(item_type, name, kwargs, arg_name): + return await func(*args, **kwargs) + + return new_func + + return decorator + + async def __aenter__(self) -> HABApp.openhab.items.OpenhabItem: + return await self.create_item() + + async def __aexit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> bool: + await self.remove() + return False + + async def remove(self) -> None: + await HABApp.openhab.interface_async.async_remove_item(self.name) + + async def _create(self, **kwargs: Unpack[ItemApiKwargs]) -> None: + interface = HABApp.openhab.interface_async + await interface.async_create_item(self._type, self._name, **kwargs) + + async def create_item(self, **kwargs: Unpack[ItemApiKwargs]) -> OpenhabItem: + + await self._create(**kwargs) + for delay in self._wait_until_item_exists(): + await asyncio.sleep(delay) + return OpenhabItem.get_item(self.name) + + async def modify(self, **kwargs: Unpack[ItemApiKwargs]) -> None: + with EventWaiter(TOPIC_ITEMS, HABApp.core.events.EventFilter(HABApp.openhab.events.ItemUpdatedEvent)) as w: + await self._create(**kwargs) + await w.async_wait_for_event() diff --git a/run/conf_testing/lib/HABAppTests/test_rule/__init__.py b/run/conf_testing/lib/HABAppTests/test_rule/__init__.py index 233adc7b..22c84b95 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/__init__.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/__init__.py @@ -1 +1,2 @@ from .test_rule import TestBaseRule, TestResult +from .test_runner_rule import TestRunnerRule diff --git a/run/conf_testing/lib/HABAppTests/test_rule/_com_patcher.py b/run/conf_testing/lib/HABAppTests/test_rule/_com_patcher.py new file mode 100644 index 00000000..bc6acd4d --- /dev/null +++ b/run/conf_testing/lib/HABAppTests/test_rule/_com_patcher.py @@ -0,0 +1,142 @@ +import json +import logging +import pprint +from collections.abc import Callable +from types import ModuleType, TracebackType +from typing import Any, Final + +from pytest import MonkeyPatch + +import HABApp.mqtt.connection.publish +import HABApp.mqtt.connection.subscribe +import HABApp.openhab.connection.handler +import HABApp.openhab.connection.handler.func_async +import HABApp.openhab.process_events +from HABApp.config import CONFIG + + +class BasePatcher: + def __init__(self, name: str, logger_name: str) -> None: + self._log: Final = logging.getLogger('Com').getChild(logger_name) + self.name: Final = name + self.monkeypatch: Final = MonkeyPatch() + + def log(self, msg: str) -> None: + self._log.debug(msg) + + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, + exc_tb: TracebackType | None) -> bool: + + self.monkeypatch.undo() + return False + + +def shorten_url(url: str) -> str: + url = str(url) + cfg = CONFIG.openhab.connection.url + if url.startswith(cfg): + return url[len(cfg):] + return url + + +class RestPatcher(BasePatcher): + + def __init__(self, name: str) -> None: + super().__init__(name, 'Rest') + + def wrap_http(self, to_call): + async def resp_wrap(*args, **kwargs): + + resp = await to_call(*args, **kwargs) + + out = '' + if kwargs.get('json') is not None: + out = f' {kwargs["json"]}' + if kwargs.get('data') is not None: + out = f' "{kwargs["data"]}"' + + self.log( + f'{resp.request_info.method:^6s} {shorten_url(resp.request_info.url)} ({resp.status}){out}' + ) + + if resp.status >= 300 and kwargs.get('log_404', True): + self.log(f'{"":6s} Header request : {resp.request_info.headers}') + self.log(f'{"":6s} Header response: {resp.headers}') + + def wrap_content(content_func): + async def content_func_wrap(*cargs, **ckwargs): + t = await content_func(*cargs, **ckwargs) + + if isinstance(t, (dict, list)): + txt = json.dumps(t, indent=2) + else: + txt = pprint.pformat(t, indent=2) + + lines = txt.splitlines() + for i, l in enumerate(lines): + self.log(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) -> None: + m = self.monkeypatch + + # http functions + to_patch: Final[tuple[tuple[ModuleType, tuple[str, ...]]], ...] = ( + (HABApp.openhab.connection.handler, ('get', 'put', 'post', 'delete')), + (HABApp.openhab.connection.handler.func_async, ('get', 'put', 'post', 'delete')), + (HABApp.openhab.connection.plugins.out, ('put', 'post')), + ) + + for module, methods in to_patch: + for name in methods: + m.setattr(module, name, self.wrap_http(getattr(module, name))) + + +class SsePatcher(BasePatcher): + + def __init__(self, name: str) -> None: + super().__init__(name, 'SSE') + + def wrap_sse(self, to_wrap: Callable[[dict], Any]) -> Callable[[dict], Any]: + def new_call(_dict: dict) -> Any: + self.log(f'{_dict}') + return to_wrap(_dict) + return new_call + + def __enter__(self) -> None: + module = HABApp.openhab.process_events + self.monkeypatch.setattr(module, 'get_event', self.wrap_sse(module.get_event)) + + +class MqttPatcher(BasePatcher): + + def __init__(self, name: str) -> None: + super().__init__(name, 'Mqtt') + + def wrap_msg(self, func: Callable[[str, Any, bool], Any]) -> Callable[[str, Any, bool], Any]: + def new_call(topic: str, payload: Any, retain: bool) -> Any: + self.log(f'{"MSG":3s} {"R" if retain else " "} {topic} {payload}') + return func(topic, payload, retain) + return new_call + + def pub_msg(self, func: Callable[[str, Any, int, bool], Any]) -> Callable[[str, Any, int, bool], Any]: + async def wrapped_publish(topic: str, payload: Any, qos: int = 0, retain: bool = False) -> Any: + self.log(f'{"PUB":3s} {"R" if retain else " "}{qos:d} {topic} {payload}') + return await func(topic, payload, qos, retain) + return wrapped_publish + + def __enter__(self) -> None: + m = self.monkeypatch + + module = HABApp.mqtt.connection.subscribe + m.setattr(module, 'msg_to_event', self.wrap_msg(module.msg_to_event)) + + obj = HABApp.mqtt.connection.publish.PUBLISH_HANDLER.plugin_connection.context + m.setattr(obj, 'publish', self.pub_msg(obj.publish)) diff --git a/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py b/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py deleted file mode 100644 index 06077def..00000000 --- a/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py +++ /dev/null @@ -1,110 +0,0 @@ -import json -import logging -import pprint -from types import TracebackType - -from pytest import MonkeyPatch # noqa: PT013 - -import HABApp.openhab.connection.handler -import HABApp.openhab.connection.handler.func_async -import HABApp.openhab.process_events -from HABApp.config import CONFIG - - -def shorten_url(url: str) -> str: - url = str(url) - cfg = CONFIG.openhab.connection.url - if url.startswith(cfg): - return url[len(cfg):] - return url - - -class RestPatcher: - def __init__(self, name: str) -> None: - self.name = name - self.logged_name = False - self._log = logging.getLogger('HABApp.Rest') - - self.monkeypatch = MonkeyPatch() - - def log(self, msg: str) -> None: - # Log name when we log the first message - if not self.logged_name: - self.logged_name = True - self._log.debug('') - self._log.debug(f'{self.name}:') - - self._log.debug(msg) - - def wrap(self, to_call): - async def resp_wrap(*args, **kwargs): - - resp = await to_call(*args, **kwargs) - - out = '' - if kwargs.get('json') is not None: - out = f' {kwargs["json"]}' - if kwargs.get('data') is not None: - out = f' "{kwargs["data"]}"' - - # Log name when we log the first message - if not self.logged_name: - self.logged_name = True - self.log('') - self.log(f'{self.name}:') - - self.log( - f'{resp.request_info.method:^6s} {shorten_url(resp.request_info.url)} ({resp.status}){out}' - ) - - if resp.status >= 300 and kwargs.get('log_404', True): - self.log(f'{"":6s} Header request : {resp.request_info.headers}') - self.log(f'{"":6s} Header response: {resp.headers}') - - def wrap_content(content_func): - async def content_func_wrap(*cargs, **ckwargs): - t = await content_func(*cargs, **ckwargs) - - if isinstance(t, (dict, list)): - txt = json.dumps(t, indent=2) - else: - txt = pprint.pformat(t, indent=2) - - lines = txt.splitlines() - for i, l in enumerate(lines): - self.log(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 wrap_sse(self, to_wrap): - def new_call(_dict): - self.log(f'{"SSE":^6s} {_dict}') - return to_wrap(_dict) - return new_call - - def __enter__(self) -> None: - m = self.monkeypatch - - # event handler - module = HABApp.openhab.process_events - m.setattr(module, 'get_event', self.wrap_sse(module.get_event)) - - # http functions - for module in (HABApp.openhab.connection.handler, HABApp.openhab.connection.handler.func_async,): - for name in ('get', 'put', 'post', 'delete'): - m.setattr(module, name, self.wrap(getattr(module, name))) - - # additional communication - module = HABApp.openhab.connection.plugins.out - m.setattr(module, 'put', self.wrap(getattr(module, 'put'))) - m.setattr(module, 'post', self.wrap(getattr(module, 'post'))) - - def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> bool: - self.monkeypatch.undo() - return False diff --git a/run/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py b/run/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py deleted file mode 100644 index 85800fae..00000000 --- a/run/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py +++ /dev/null @@ -1,62 +0,0 @@ -import threading -import typing - -import HABAppTests - -from ._rule_status import TestRuleStatus - - -LOCK = threading.Lock() - - -RULE_CTR = 0 -TESTS_RULES: typing.Dict[int, 'HABAppTests.TestBaseRule'] = {} - - -class RuleID: - def __init__(self, id: int) -> None: - self.__id = id - - def is_newest(self) -> bool: - with LOCK: - if self.__id != RULE_CTR: - return False - return True - - def remove(self) -> None: - pop_test_rule(self.__id) - - -def get_next_id(rule) -> RuleID: - global RULE_CTR - with LOCK: - RULE_CTR += 1 - TESTS_RULES[RULE_CTR] = rule - - obj = RuleID(RULE_CTR) - return obj - - -def pop_test_rule(id: int) -> None: - with LOCK: - rule = TESTS_RULES.pop(id) - rule._rule_status = TestRuleStatus.FINISHED - - -def get_test_rules() -> typing.Iterable['HABAppTests.TestBaseRule']: - ret = [] - for k, rule in sorted(TESTS_RULES.items()): - assert isinstance(rule, HABAppTests.TestBaseRule) - if rule._rule_status is not TestRuleStatus.CREATED: - continue - ret.append(rule) - - return tuple(ret) - - -def test_rules_running() -> bool: - for rule in TESTS_RULES.values(): - status = rule._rule_status - if status is not TestRuleStatus.CREATED and status is not TestRuleStatus.FINISHED: - return True - return False diff --git a/run/conf_testing/lib/HABAppTests/test_rule/_rule_status.py b/run/conf_testing/lib/HABAppTests/test_rule/_rule_status.py deleted file mode 100644 index 27df05eb..00000000 --- a/run/conf_testing/lib/HABAppTests/test_rule/_rule_status.py +++ /dev/null @@ -1,8 +0,0 @@ -from enum import Enum, auto - - -class TestRuleStatus(Enum): - CREATED = auto() - PENDING = auto() - RUNNING = auto() - FINISHED = auto() diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_case.py b/run/conf_testing/lib/HABAppTests/test_rule/test_case.py new file mode 100644 index 00000000..c1b4d954 --- /dev/null +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_case.py @@ -0,0 +1,146 @@ +import functools +import logging +from asyncio import sleep +from collections.abc import Callable +from inspect import iscoroutinefunction +from typing import Any, Coroutine, Final, Sequence + +import HABApp +from HABApp.core.events import NoEventFilter +from HABApp.core.internals import EventBusListener, wrap_func, WrappedFunctionBase +from HABAppTests.test_rule._com_patcher import BasePatcher, MqttPatcher, RestPatcher, SsePatcher +from HABAppTests.test_rule.test_result import TestResult, TestResultStatus + + +class TmpLogLevel: + def __init__(self, name: str): + self.log = logging.getLogger(name) + self.old = self.log.level + + def __enter__(self): + self.old = self.log.level + self.log.setLevel(logging.INFO) + + def __exit__(self, exc_type, exc_val, exc_tb): + self.log.setLevel(self.old) + return False + + +class ExecutionEventCatcher: + def __init__(self, event_name: str, event_bus_name: str) -> None: + self._event_name: Final = event_name + self._event_bus_name: Final = event_bus_name + + self._listener: EventBusListener | None = None + self._events = [] + + async def _event(self, event: Any) -> None: + self._events.append(event) + + def get_message(self) -> str: + if not self._events: + return '' + return f'{(ct := len(self._events))} {self._event_name}{"s" if ct != 1 else ""} in worker' + + async def __aenter__(self): + if self._listener is None: + ebl = EventBusListener(self._event_bus_name, wrap_func(self._event), NoEventFilter()) + self._listener = ebl + + with TmpLogLevel('HABApp'): + HABApp.core.EventBus.add_listener(ebl) + + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + if (ebl := self._listener) is not None: + self._listener = None + with TmpLogLevel('HABApp'): + ebl.cancel() + return False + + +def tc_wrap_func(func: Callable | Callable[[...], Coroutine], res: TestResult) -> WrappedFunctionBase: + if iscoroutinefunction(func): + @functools.wraps(func) + async def tc_async_wrapped(*args, **kwargs): + try: + return await func(*args, **kwargs) + except Exception as e: + res.exception(e) + return None + + return wrap_func(tc_async_wrapped) + + @functools.wraps(func) + def tc_wrapped(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception as e: + res.exception(e) + return None + + return wrap_func(tc_wrapped) + + +class TestCase: + def __init__(self, name: str, func: Callable | Callable[[...], Coroutine], + args: Sequence[Any] = (), kwargs: dict[str, Any] | None = None, + setup_up: Callable | Callable[[...], Coroutine] | None = None, + tear_down: Callable | Callable[[...], Coroutine] | None = None) -> None: + self.name: Final = name + self.func: Final = func + self.args: Final = args + self.kwargs: Final = kwargs if kwargs is not None else {} + + self.set_up: Final = setup_up if setup_up is not None else None + self.tear_down: Final = tear_down if tear_down is not None else None + + async def run(self, res: TestResult) -> TestResult: + + async with ExecutionEventCatcher('warning', HABApp.core.const.topics.TOPIC_WARNINGS) as worker_warnings, \ + ExecutionEventCatcher('error', HABApp.core.const.topics.TOPIC_ERRORS) as worker_errors: + + name = f'{res.cls_name}.{res.test_name}' + + b = BasePatcher(name, 'TC') + b.log('') + b.log(name) + + try: + with RestPatcher(name), SsePatcher(name), MqttPatcher(name): + if s := self.set_up: + await tc_wrap_func(s, res).async_run() + await sleep(0.1) + b.log('Setup done') + + ret = await tc_wrap_func(self.func, res).async_run(*self.args, **self.kwargs) + if ret: + res.set_state(TestResultStatus.FAILED) + res.add_msg(f'{ret}') + else: + res.set_state(TestResultStatus.PASSED) + + await sleep(0.1) + except Exception as e: + res.exception(e) + + try: + if t := self.tear_down: + b.log('Tear down') + await tc_wrap_func(t, res).async_run() + await sleep(0.1) + except Exception as e: + res.exception(e) + + await sleep(0.1) + + if msg := worker_warnings.get_message(): + res.set_state(TestResultStatus.WARNING) + res.add_msg(msg) + + if msg := worker_errors.get_message(): + res.set_state(TestResultStatus.ERROR) + res.add_msg(msg) + + return res diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py b/run/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py deleted file mode 100644 index a77584fd..00000000 --- a/run/conf_testing/lib/HABAppTests/test_rule/test_case/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .test_result import TestResultStatus, TestResult -# isort: split -from .test_case import TestCase diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py b/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py deleted file mode 100644 index 02035508..00000000 --- a/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_case.py +++ /dev/null @@ -1,30 +0,0 @@ -import time -from collections.abc import Callable - -from HABAppTests.test_rule._rest_patcher import RestPatcher -from HABAppTests.test_rule.test_case import TestResult, TestResultStatus - - -class TestCase: - def __init__(self, name: str, func: Callable, args=[], kwargs={}) -> None: - self.name = name - self.func = func - self.args = args - self.kwargs = kwargs - - def run(self, res: TestResult) -> TestResult: - time.sleep(0.05) - - try: - with RestPatcher(f'{res.cls_name}.{res.test_name}'): - ret = self.func(*self.args, **self.kwargs) - if ret: - res.set_state(TestResultStatus.FAILED) - res.add_msg(f'{ret}') - else: - res.set_state(TestResultStatus.PASSED) - except Exception as e: - res.exception(e) - - time.sleep(0.05) - return res diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py b/run/conf_testing/lib/HABAppTests/test_rule/test_result.py similarity index 78% rename from run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py rename to run/conf_testing/lib/HABAppTests/test_rule/test_result.py index 4dbb1b3a..94275ad8 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_result.py @@ -1,8 +1,9 @@ import logging from enum import IntEnum, auto +from typing import Final import HABApp -from HABAppTests.errors import TestCaseFailed, TestCaseWarning +from HABAppTests.errors import TestCaseFailed log = logging.getLogger('HABApp.Tests') @@ -29,29 +30,28 @@ class TestResultStatus(IntEnum): class TestResult: def __init__(self, cls_name: str, test_name: str, test_nr: str = '') -> None: - self.cls_name = cls_name - self.test_name = test_name - self.test_nr = test_nr + self.cls_name: Final = cls_name + self.test_name: Final = test_name + self.test_nr: Final = test_nr self.state = TestResultStatus.NOT_SET - self.msgs: list[str] = [] - - def is_set(self): - return self.state != TestResultStatus.NOT_SET + self.msgs: Final[list[str]] = [] def set_state(self, new_state: TestResultStatus) -> None: if self.state <= new_state: self.state = new_state - def exception(self, e: Exception): + def exception(self, e: Exception) -> None: if isinstance(e, TestCaseFailed): self.set_state(TestResultStatus.FAILED) self.add_msg(e.msg) return None - if isinstance(e, TestCaseWarning): - self.set_state(TestResultStatus.WARNING) - self.add_msg(e.msg) - return None + + # if isinstance(e, AssertionError): + # self.set_state(TestResultStatus.FAILED) + # self.add_msg(f'{e}') + # self.add_msg(f' {e.args}') + # return None self.add_msg(f'Exception: {e}') self.state = TestResultStatus.ERROR @@ -63,7 +63,7 @@ def add_msg(self, msg: str) -> None: for line in msg.splitlines(): self.msgs.append(line) - def log(self, name: str | None = None): + def log(self, name: str | None = None) -> None: if name is None: name = f'{self.cls_name}.{self.test_name}' nr = f' {self.test_nr} ' if self.test_nr else ' ' @@ -83,3 +83,4 @@ def log(self, name: str | None = None): log_func(f'{prefix} {self.state.name.lower()}: {first_msg}') for msg in self.msgs[1:]: log_func(f'{"":8s}{msg}') + return None diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py b/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py index 9250ab0f..6cb38a6b 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py @@ -1,12 +1,18 @@ import logging -from collections.abc import Callable -from pathlib import Path +from collections.abc import Callable, Coroutine +from enum import Enum, auto +from typing import Any, overload, Self import HABApp from HABAppTests.test_rule.test_case import TestCase, TestResult, TestResultStatus +from HABAppTests.utils import get_file_path_of_obj -from ._rule_ids import get_next_id, get_test_rules, test_rules_running -from ._rule_status import TestRuleStatus + +class TestRuleStatus(Enum): + CREATED = auto() + PENDING = auto() + RUNNING = auto() + FINISHED = auto() log = logging.getLogger('HABApp.Tests') @@ -19,181 +25,86 @@ def __init__(self) -> None: class TestBaseRule(HABApp.Rule): - """This rule is testing the OpenHAB data types by posting values and checking the events""" - def __init__(self) -> None: super().__init__() + + self.config = TestConfig() self._rule_status = TestRuleStatus.CREATED - self._rule_id = get_next_id(self) - self._tests: dict[str, TestCase] = {} + self._test_cases: dict[str, TestCase] = {} - self.__warnings = [] - self.__errors = [] - self.__sub_warning = None - self.__sub_errors = None + @overload + def set_up(self): ... - self.config = TestConfig() + @overload + async def set_up(self): ... - self.__worst_result = TestResultStatus.PASSED + def set_up(self): + pass - # we have to chain the rules later, because we register the rules only once we loaded successfully. - self.run.at(2, self.__execute_run) + @overload + def tear_down(self): ... - def on_rule_unload(self) -> None: - self._rule_id.remove() + @overload + async def tear_down(self): ... - # ------------------------------------------------------------------------------------------------------------------ - # Overrides and test - def set_up(self) -> None: + def tear_down(self): pass - def tear_down(self) -> None: - pass + def add_test(self, name: str, func: Callable | Callable[[...], Coroutine], *args: Any, + setup_up: Callable | Callable[[...], Coroutine] | None = None, + tear_down: Callable | Callable[[...], Coroutine] | None = None, + **kwargs: Any) -> Self: - def add_test(self, name, func: Callable, *args, **kwargs) -> None: - tc = TestCase(name, func, args, kwargs) - assert tc.name not in self._tests - self._tests[tc.name] = tc - - # ------------------------------------------------------------------------------------------------------------------ - # Rule execution - def __execute_run(self): - if not self._rule_id.is_newest(): - return None - - # If we currently run a test wait until it is complete - if test_rules_running(): - self.run.at(2, self.__execute_run) - return None - - ergs = [] - rules = get_test_rules() - for rule in rules: - # mark rules for execution - rule._rule_status = TestRuleStatus.PENDING - for rule in rules: - # It's possible that we unload a rule before it was run - if rule._rule_status is not TestRuleStatus.PENDING: - continue - ergs.extend(rule._run_tests()) - - skipped = tuple(filter(lambda x: x.state is TestResultStatus.SKIPPED, ergs)) - passed = tuple(filter(lambda x: x.state is TestResultStatus.PASSED, ergs)) - warning = tuple(filter(lambda x: x.state is TestResultStatus.WARNING, ergs)) - failed = tuple(filter(lambda x: x.state is TestResultStatus.FAILED, ergs)) - error = tuple(filter(lambda x: x.state is TestResultStatus.ERROR, ergs)) - - def plog(msg: str) -> None: - print(msg) - log.info(msg) - - parts = [f'{len(ergs)} executed', f'{len(passed)} passed'] - if skipped: - parts.append(f'{len(skipped)} skipped') - if warning: - parts.append(f'{len(warning)} warning{"" if len(warning) == 1 else "s"}') - parts.append(f'{len(failed)} failed') - if error: - parts.append(f'{len(error)} error{"" if len(error) == 1 else "s"}') - - plog('') - plog('-' * 120) - plog(', '.join(parts)) - - # ------------------------------------------------------------------------------------------------------------------ - # Event from the worker - def __event_warning(self, event) -> None: - self.__warnings.append(event) - - def __event_error(self, event) -> None: - self.__errors.append(event) - - def _worker_events_sub(self) -> None: - assert self.__sub_warning is None - assert self.__sub_errors is None - self.__sub_warning = self.listen_event(HABApp.core.const.topics.TOPIC_WARNINGS, self.__event_warning) - self.__sub_errors = self.listen_event(HABApp.core.const.topics.TOPIC_ERRORS, self.__event_error) - - def _worker_events_cancel(self) -> None: - if self.__sub_warning is not None: - self.__sub_warning.cancel() - if self.__sub_errors is not None: - self.__sub_errors.cancel() - - # ------------------------------------------------------------------------------------------------------------------ - # Test execution - def __exec_tc(self, res: TestResult, tc: TestCase) -> None: - self.__warnings.clear() - self.__errors.clear() - - tc.run(res) - - if self.__warnings: - res.set_state(TestResultStatus.WARNING) - ct = len(self.__warnings) - msg = f'{ct} warning{"s" if ct != 1 else ""} in worker' - res.add_msg(msg) - self.__warnings.clear() - - if self.__errors: - res.set_state(TestResultStatus.ERROR) - ct = len(self.__errors) - msg = f'{ct} error{"s" if ct != 1 else ""} in worker' - res.add_msg(msg) - self.__errors.clear() - - self.__worst_result = max(self.__worst_result, res.state) - - def _run_tests(self) -> list[TestResult]: + tc = TestCase(name, func, args, kwargs, setup_up, tear_down) + assert tc.name not in self._test_cases + self._test_cases[tc.name] = tc + return self + + async def run_test_cases(self) -> list[TestResult]: self._rule_status = TestRuleStatus.RUNNING - self._worker_events_sub() - results = [] + results: list[TestResult] = [] # setup tc = TestCase('set_up', self.set_up) tr = TestResult(self.__class__.__name__, tc.name) - self.__exec_tc(tr, tc) + await tc.run(tr) if tr.state is not tr.state.PASSED: results.append(tr) - results.extend(self.__run_tests()) + results.extend(await self._run_rule_tests()) # tear down - tc = TestCase('tear_down', self.set_up) + tc = TestCase('tear_down', self.tear_down) tr = TestResult(self.__class__.__name__, tc.name) - self.__exec_tc(tr, tc) + await tc.run(tr) if tr.state is not tr.state.PASSED: results.append(tr) - self._worker_events_cancel() self._rule_status = TestRuleStatus.FINISHED return results - def __run_tests(self) -> list[TestResult]: - count = len(self._tests) - width = 1 - while count >= 10 ** width: - width += 1 + async def _run_rule_tests(self) -> list[TestResult]: + count = len(self._test_cases) + width = len(str(count)) c_name = self.__class__.__name__ results = [ - TestResult(c_name, tc.name, f'{i + 1:{width}d}/{count}') for i, tc in enumerate(self._tests.values()) + TestResult(c_name, tc.name, f'{i:{width}d}/{count}') for i, tc in enumerate(self._test_cases.values(), 1) ] - module_of_class = Path(self.__class__.__module__) - relative_path = module_of_class.relative_to(HABApp.CONFIG.directories.rules) - log.info('') - log.info(f'Running {count} tests for {c_name} (from "{relative_path}")') + log.info( + f'Running {count:d} test{"s" if count != 1 else ""} for {c_name:s} (from "{get_file_path_of_obj(self)}")' + ) - for res, tc in zip(results, self._tests.values()): - if self.config.skip_on_failure and self.__worst_result >= TestResultStatus.FAILED: + for res, tc in zip(results, self._test_cases.values(), strict=True): + if self.config.skip_on_failure and max(r.state for r in results) >= TestResultStatus.FAILED: res.set_state(TestResultStatus.SKIPPED) res.log() continue - self.__exec_tc(res, tc) + await tc.run(res) res.log() return results diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_runner_rule.py b/run/conf_testing/lib/HABAppTests/test_rule/test_runner_rule.py new file mode 100644 index 00000000..3ef48522 --- /dev/null +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_runner_rule.py @@ -0,0 +1,66 @@ +import logging + +import HABApp +from HABApp.core.const.topics import TOPIC_FILES +from HABApp.core.lib import SingleTask +from HABAppTests.test_rule.test_case import TestResult, TestResultStatus + +from .test_rule import TestBaseRule, TestRuleStatus +from HABApp.core import shutdown + +log = logging.getLogger('HABApp.Tests') + + +class TestRunnerRule(HABApp.Rule): + def __init__(self) -> None: + super().__init__() + + self.listen_event(TOPIC_FILES, self._file_event) + self.countdown = self.run.countdown(3, self._files_const) + self.countdown.reset() + + self.task = SingleTask(self._run_tests) + + async def _file_event(self, event) -> None: + self.countdown.reset() + + async def _files_const(self) -> None: + self.task.start_if_not_running() + + def _get_next_rule(self) -> TestBaseRule | None: + all_rules = [r for r in self.get_rule(None) if isinstance(r, TestBaseRule)] + for rule in all_rules: + if rule._rule_status is TestRuleStatus.CREATED: + rule._rule_status = TestRuleStatus.PENDING + if rule._rule_status is TestRuleStatus.PENDING: + return rule + return None + + async def _run_tests(self) -> None: + results: list[TestResult] = [] + + while (rule := self._get_next_rule()) is not None and not shutdown.is_requested(): + results.extend(await rule.run_test_cases()) + + skipped = tuple(x for x in results if x.state is TestResultStatus.SKIPPED) + passed = tuple(x for x in results if x.state is TestResultStatus.PASSED) + warning = tuple(x for x in results if x.state is TestResultStatus.WARNING) + failed = tuple(x for x in results if x.state is TestResultStatus.FAILED) + error = tuple(x for x in results if x.state is TestResultStatus.ERROR) + + def plog(msg: str) -> None: + print(msg) + log.info(msg) + + parts = [f'{len(results)} executed', f'{len(passed)} passed'] + if skipped: + parts.append(f'{len(skipped)} skipped') + if warning: + parts.append(f'{len(warning)} warning{"" if len(warning) == 1 else "s"}') + parts.append(f'{len(failed)} failed') + if error: + parts.append(f'{len(error)} error{"" if len(error) == 1 else "s"}') + + plog('') + plog('-' * 120) + plog(', '.join(parts)) diff --git a/run/conf_testing/lib/HABAppTests/utils.py b/run/conf_testing/lib/HABAppTests/utils.py index f810366e..5e969e9b 100644 --- a/run/conf_testing/lib/HABAppTests/utils.py +++ b/run/conf_testing/lib/HABAppTests/utils.py @@ -3,6 +3,7 @@ import string import typing from binascii import b2a_hex +from pathlib import Path import HABApp from HABApp.openhab.items import Thing @@ -16,18 +17,19 @@ def __get_fill_char(skip: str, upper=False) -> str: - skip += 'il' + skip += 'ilo' skip = skip.upper() if upper else skip.lower() - rnd = random.choice(string.ascii_uppercase if upper else string.ascii_lowercase) - while rnd in skip: - rnd = random.choice(string.ascii_uppercase if upper else string.ascii_lowercase) + + letters = string.ascii_uppercase if upper else string.ascii_lowercase + while (rnd := random.choice(letters)) in skip: + pass return rnd def get_random_name(item_type: str) -> str: name = name_prev = __RAND_PREFIX[item_type.split(':')[0]] - for c in range(3): + for _ in range(3): name += __get_fill_char(name_prev, upper=True) while len(name) < 10: @@ -35,21 +37,21 @@ def get_random_name(item_type: str) -> str: return name -def run_coro(coro: typing.Coroutine): - fut = asyncio.run_coroutine_threadsafe(coro, HABApp.core.const.loop) - return fut.result() - - def find_astro_sun_thing() -> str: items = HABApp.core.Items.get_items() for item in items: if isinstance(item, Thing) and item.name.startswith('astro:sun'): return item.name - raise ValueError('No astro thing found!') + msg = 'No astro thing found!' + raise ValueError(msg) + +def get_file_path_of_obj(obj: typing.Any) -> str: + try: + module = obj.__module__ + except AttributeError: + module = obj.__class__.__module__ -def get_bytes_text(value): - if isinstance(value, bytes) and len(value) > 300: - return b2a_hex(value[:40]).decode() + ' ... ' + b2a_hex(value[-40:]).decode() - return value + module_of_class = Path(module) + return str(module_of_class.relative_to(HABApp.CONFIG.directories.rules)) diff --git a/run/conf_testing/logging.yml b/run/conf_testing/logging.yml index 079dcc1e..1df2b975 100644 --- a/run/conf_testing/logging.yml +++ b/run/conf_testing/logging.yml @@ -1,7 +1,7 @@ formatters: HABApp_format: format: '[%(asctime)s] [%(name)25s] %(levelname)8s | %(message)s' - HABApp_REST: + HABApp_COM: format: '[%(asctime)s] [%(name)11s] %(levelname)8s | %(message)s' @@ -33,13 +33,13 @@ handlers: formatter: HABApp_format level: DEBUG - HABApp_rest_file: + HABApp_com_file: class: logging.handlers.RotatingFileHandler - filename: 'test_rest.log' + filename: 'test_com.log' maxBytes: 10_485_760 backupCount: 3 - formatter: HABApp_REST + formatter: HABApp_COM level: DEBUG @@ -74,8 +74,9 @@ loggers: - HABApp_test_file propagate: False - HABApp.Rest: + Com: level: DEBUG handlers: - - HABApp_rest_file + - HABApp_com_file propagate: False + diff --git a/run/conf_testing/rules/habapp/test_event_listener.py b/run/conf_testing/rules/habapp/test_event_listener.py index 77e3bf3a..573ebf74 100644 --- a/run/conf_testing/rules/habapp/test_event_listener.py +++ b/run/conf_testing/rules/habapp/test_event_listener.py @@ -1,5 +1,3 @@ -import logging - from HABAppTests import TestBaseRule, get_random_name from HABApp.core.events import ValueChangeEventFilter @@ -8,9 +6,6 @@ from HABApp.util import EventListenerGroup -log = logging.getLogger('HABApp.Tests.MultiMode') - - class TestNoWarningOnRuleUnload(TestBaseRule): """This rule tests that multiple listen/cancel commands don't create warnings on unload""" @@ -30,12 +25,6 @@ def test_unload(self) -> None: self._habapp_ctx.unload_rule() - # workaround so we don't get Errors - for k in ['_TestBaseRule__sub_warning', '_TestBaseRule__sub_errors']: - obj = self.__dict__[k] - self.__dict__[k] = None - assert obj._parent_ctx is None - # Workaround to so we don't crash self.on_rule_unload = lambda: None self._habapp_ctx = HABAppRuleContext(self) diff --git a/run/conf_testing/rules/openhab/test_habapp_internals.py b/run/conf_testing/rules/openhab/test_habapp_internals.py index 6492829e..af1bb9ed 100644 --- a/run/conf_testing/rules/openhab/test_habapp_internals.py +++ b/run/conf_testing/rules/openhab/test_habapp_internals.py @@ -1,4 +1,4 @@ -from HABAppTests import OpenhabTmpItem, TestBaseRule, run_coro +from HABAppTests import AsyncOpenhabTmpItem, TestBaseRule from HABApp.openhab.connection.handler.func_async import ( async_get_item_with_habapp_meta, @@ -14,31 +14,31 @@ def __init__(self) -> None: super().__init__() self.add_test('async', self.create_meta) - def create_meta(self) -> None: - with OpenhabTmpItem('String') as tmpitem: - d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) + @AsyncOpenhabTmpItem.create('String', arg_name='tmpitem') + async def create_meta(self, tmpitem: AsyncOpenhabTmpItem) -> None: + d = await async_get_item_with_habapp_meta(tmpitem.name) assert d.metadata['HABApp'] is None # create empty set - run_coro(async_set_habapp_metadata(tmpitem.name, HABAppThingPluginData())) + await async_set_habapp_metadata(tmpitem.name, HABAppThingPluginData()) - d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) + d = await async_get_item_with_habapp_meta(tmpitem.name) assert isinstance(d.metadata['HABApp'], HABAppThingPluginData) # create valid data - run_coro(async_set_habapp_metadata( - tmpitem.name, HABAppThingPluginData(created_link='asdf', created_ns=['a', 'b'])) + await async_set_habapp_metadata( + tmpitem.name, HABAppThingPluginData(created_link='asdf', created_ns=['a', 'b']) ) - d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) + d = await async_get_item_with_habapp_meta(tmpitem.name) d = d.metadata['HABApp'] assert isinstance(d, HABAppThingPluginData) assert d.created_link == 'asdf' assert d.created_ns == ['a', 'b'] # remove metadata again - run_coro(async_remove_habapp_metadata(tmpitem.name)) - d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) + await async_remove_habapp_metadata(tmpitem.name) + d = await async_get_item_with_habapp_meta(tmpitem.name) assert d.metadata['HABApp'] is None diff --git a/run/conf_testing/rules/openhab/test_items.py b/run/conf_testing/rules/openhab/test_items.py index b6a2e834..ff3a55ba 100644 --- a/run/conf_testing/rules/openhab/test_items.py +++ b/run/conf_testing/rules/openhab/test_items.py @@ -1,23 +1,27 @@ -import asyncio -from datetime import datetime + +from typing import TYPE_CHECKING from HABAppTests import EventWaiter, ItemWaiter, OpenhabTmpItem, TestBaseRule from immutables import Map from whenever import Instant, OffsetDateTime, SystemDateTime -from HABApp.core.const import loop from HABApp.core.events import ValueUpdateEventFilter from HABApp.core.types import HSB, RGB from HABApp.openhab.interface_async import async_get_items from HABApp.openhab.items import ColorItem, DatetimeItem, GroupItem, NumberItem, StringItem +if TYPE_CHECKING: + from datetime import datetime + + class OpenhabItems(TestBaseRule): def __init__(self) -> None: super().__init__() - self.add_test('ApiDoc', self.test_api) + self.add_test('Api', self.test_api) + self.add_test('AsyncApi', self.test_api_async) self.add_test('MemberTags', self.test_tags) self.add_test('MemberGroups', self.test_groups) self.add_test('TestExisting', self.test_existing) @@ -68,7 +72,9 @@ def test_api(self) -> None: self.openhab.get_item(self.item_switch.name) self.openhab.get_item(self.item_group.name) - asyncio.run_coroutine_threadsafe(async_get_items(), loop).result() + + async def test_api_async(self) -> None: + await async_get_items() @OpenhabTmpItem.create('Number', arg_name='tmp_item') def test_small_float_values(self, tmp_item: OpenhabTmpItem) -> None: diff --git a/run/conf_testing/rules/openhab/test_links.py b/run/conf_testing/rules/openhab/test_links.py index 50be3052..b958d0f0 100644 --- a/run/conf_testing/rules/openhab/test_links.py +++ b/run/conf_testing/rules/openhab/test_links.py @@ -1,5 +1,4 @@ from HABAppTests import TestBaseRule -from HABAppTests.utils import find_astro_sun_thing, run_coro from HABApp.openhab.connection.handler.func_async import async_get_link, async_get_links @@ -8,14 +7,7 @@ class OpenhabLinkApi(TestBaseRule): def __init__(self) -> None: super().__init__() - self.add_test('AllLinks', self.wrap_async, self.api_get_links) - - def wrap_async(self, coro, *args, **kwargs) -> None: - # create valid data - run_coro(coro(*args, **kwargs)) - - def set_up(self) -> None: - self.thing = self.openhab.get_thing(find_astro_sun_thing()) + self.add_test('AllLinks', self.api_get_links) async def api_get_links(self) -> None: objs = await async_get_links() diff --git a/run/conf_testing/rules/test_mqtt.py b/run/conf_testing/rules/test_mqtt.py index db22735d..9ba9a526 100644 --- a/run/conf_testing/rules/test_mqtt.py +++ b/run/conf_testing/rules/test_mqtt.py @@ -1,7 +1,7 @@ +import asyncio import logging -import time -from HABAppTests import EventWaiter, ItemWaiter, TestBaseRule, run_coro +from HABAppTests import EventWaiter, ItemWaiter, TestBaseRule import HABApp from HABApp.core.connections import Connections, ConnectionStatus @@ -63,23 +63,24 @@ def test_mqtt_state(self) -> None: my_item.publish(data) waiter.wait_for_state(data) - def test_mqtt_item_creation(self) -> None: + async def test_mqtt_item_creation(self) -> None: topic = 'mqtt/item/creation' assert HABApp.core.Items.item_exists(topic) is False self.mqtt.publish(topic, 'asdf') - time.sleep(0.1) + await asyncio.sleep(0.1) assert HABApp.core.Items.item_exists(topic) is False # We create the item only on retain self.mqtt.publish(topic, 'asdf', retain=True) - time.sleep(0.2) + await asyncio.sleep(0.2) - run_coro(self.trigger_reconnect()) + await self.trigger_reconnect() + await asyncio.sleep(0.2) connection = Connections.get('mqtt') while not connection.is_online: - time.sleep(0.2) + await asyncio.sleep(0.2) assert HABApp.core.Items.item_exists(topic) is True diff --git a/run/conf_testing/rules/test_runner.py b/run/conf_testing/rules/test_runner.py new file mode 100644 index 00000000..c6899d84 --- /dev/null +++ b/run/conf_testing/rules/test_runner.py @@ -0,0 +1,4 @@ +from HABAppTests import TestRunnerRule + + +TestRunnerRule() diff --git a/src/HABApp/core/internals/event_bus/event_bus.py b/src/HABApp/core/internals/event_bus/event_bus.py index a9513b7a..68de1976 100644 --- a/src/HABApp/core/internals/event_bus/event_bus.py +++ b/src/HABApp/core/internals/event_bus/event_bus.py @@ -13,7 +13,7 @@ class EventBus: - __slots__ = ('_lock', '_listeners') + __slots__ = ('_listeners', '_lock') def __init__(self) -> None: self._lock = threading.Lock()