From 40eeb51ec4f09ceee1875245c60bf8d2813378a6 Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Wed, 9 Oct 2024 14:14:18 +0200 Subject: [PATCH] - Added config for country - reworked last_change and last_update --- docs/class_reference.rst | 10 ++ docs/conf.py | 4 +- docs/getting_started.rst | 25 +++- docs/requirements.txt | 6 +- readme.md | 3 + requirements.txt | 4 +- requirements_setup.txt | 2 +- src/HABApp/core/items/base_item.py | 9 +- src/HABApp/core/lib/__init__.py | 1 + src/HABApp/core/lib/instant_view.py | 109 ++++++++++++++++++ src/HABApp/openhab/connection/connection.py | 6 +- .../openhab/connection/plugins/load_items.py | 3 +- src/HABApp/rule/scheduler/__init__.py | 1 + tests/test_core/test_items/item_tests.py | 10 +- tests/test_core/test_lib/test_instant_view.py | 54 +++++++++ 15 files changed, 221 insertions(+), 26 deletions(-) create mode 100644 src/HABApp/core/lib/instant_view.py create mode 100644 tests/test_core/test_lib/test_instant_view.py diff --git a/docs/class_reference.rst b/docs/class_reference.rst index 94415b57..37732935 100644 --- a/docs/class_reference.rst +++ b/docs/class_reference.rst @@ -24,3 +24,13 @@ ItemNoChangeWatch :members: :inherited-members: :member-order: groupwise + + + +InstantView +====================================== + +.. autoclass:: HABApp.core.lib.InstantView + :members: + :inherited-members: + :member-order: groupwise diff --git a/docs/conf.py b/docs/conf.py index ad07ed97..526958ae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -123,7 +123,6 @@ def log(msg: str) -> None: 'canonical_url': '', # 'analytics_id': 'UA-XXXXXXX-1', # Provided by Google in your dashboard 'logo_only': False, - 'display_version': True, 'prev_next_buttons_location': 'bottom', 'style_external_links': False, # 'vcs_pageview_mode': '', @@ -338,7 +337,8 @@ def setup(app) -> None: # https://www.sphinx-doc.org/en/master/usage/extensions/intersphinx.html if IS_RTD_BUILD: intersphinx_mapping = { - 'python': ('https://docs.python.org/3', None) + 'python': ('https://docs.python.org/3', None), + 'whenever': ('https://whenever.readthedocs.io/en/stable', None) } # Don't show warnings for missing python references since these are created via intersphinx during the RTD build diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 75c45b99..3998f975 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -171,24 +171,30 @@ All items have two additional timestamps set which can be used to simplify rule * The time when the item was last updated * The time when the item was last changed. +It's possible to compare these values directly with deltas without having to do calculations withs timestamps + .. exec_code:: # ------------ hide: start ------------ - from whenever import Instant + from whenever import Instant, patch_current_time from HABApp.core.items import Item from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - item = Item.get_create_item('Item_Name', initial_value='old_value') + item = Item.get_create_item('Item_Name', initial_value='value') item._last_change.instant = Instant.from_utc(2024, 4, 30, 10, 30) - item._last_update.instant = Instant.from_utc(2024, 4, 30, 12, 16) + item._last_update.instant = Instant.from_utc(2024, 4, 30, 10, 31) + + p = patch_current_time(item._last_update.instant.add(minutes=1), keep_ticking=False) + p.__enter__() # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item + from HABApp.rule.scheduler import minutes, seconds class TimestampRule(HABApp.Rule): def __init__(self): @@ -197,12 +203,21 @@ All items have two additional timestamps set which can be used to simplify rule self.my_item = Item.get_item('Item_Name') # Access of timestamps - print(f'Last update: {self.my_item.last_update}') - print(f'Last change: {self.my_item.last_change}') + + # It's possible to compare directly with the most common (time-) deltas through the operator + if self.my_item.last_update >= minutes(1): + print('Item was updated in the last minute') + + # There are also functions available which support both building the delta directly and using an object + if self.my_item.last_change.newer_than(minutes=2, seconds=30): + print('Item was changed in the last 1min 30s') + if self.my_item.last_change.older_than(seconds(30)): + print('Item was changed before 30s') TimestampRule() # ------------ hide: start ------------ + p.__exit__(None, None, None) runner.tear_down() # ------------ hide: stop ------------- diff --git a/docs/requirements.txt b/docs/requirements.txt index 222a4b9c..323ae56e 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ # Packages required to build the documentation -sphinx == 7.4.7 -sphinx-autodoc-typehints == 2.3.0 -sphinx_rtd_theme == 2.0.0 +sphinx == 8.0.2 +sphinx-autodoc-typehints == 2.5.0 +sphinx_rtd_theme == 3.0.0 sphinx-exec-code == 0.12 autodoc_pydantic == 2.2.0 sphinx-copybutton == 0.5.2 diff --git a/readme.md b/readme.md index 65f9d8ef..a65d4183 100644 --- a/readme.md +++ b/readme.md @@ -135,6 +135,9 @@ Changelog: Migration of rules: - Search for ``self.run.at`` and replace with ``self.run.once`` +- ``item.last_update`` and ``item.last_change`` can now directly used to check if it's newer/older than a delta. + Replace ``item.last_update > datetime_obj`` with ``item.last_update > timedelta_obj`` or + ``item.last_update.newer_than(minutes=10)`` #### 24.08.1 (2024-08-02) - Fixed a possible infinite loop during thing re-sync diff --git a/requirements.txt b/requirements.txt index 07e2f647..43217de9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,8 +6,8 @@ # ----------------------------------------------------------------------------- # Packages for source formatting # ----------------------------------------------------------------------------- -pre-commit == 3.8.0 -ruff == 0.6.8 +pre-commit == 4.0.1 +ruff == 0.6.9 autotyping == 24.9.0 # ----------------------------------------------------------------------------- # Packages for other developement tasks diff --git a/requirements_setup.txt b/requirements_setup.txt index c11319b9..c3e845e9 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,4 +1,4 @@ -aiohttp == 3.10.8 +aiohttp == 3.10.9 pydantic == 2.9.2 msgspec == 0.18.6 bidict == 0.23.1 diff --git a/src/HABApp/core/items/base_item.py b/src/HABApp/core/items/base_item.py index 5d7d2154..15bc0ba7 100644 --- a/src/HABApp/core/items/base_item.py +++ b/src/HABApp/core/items/base_item.py @@ -12,6 +12,7 @@ uses_item_registry, ) from HABApp.core.internals.item_registry import ItemRegistryItem +from HABApp.core.lib import InstantView from HABApp.core.lib.parameters import TH_POSITIVE_TIME_DIFF, get_positive_time_diff from .base_item_times import ChangedTime, ItemNoChangeWatch, ItemNoUpdateWatch, UpdatedTime @@ -46,18 +47,18 @@ def __init__(self, name: str) -> None: self._last_update: UpdatedTime = UpdatedTime(self._name, _now) @property - def last_change(self) -> dt_datetime: + def last_change(self) -> InstantView: """ :return: Timestamp of the last time when the item has been changed (read only) """ - return self._last_change.instant.to_system_tz().local().py_datetime() + return InstantView(self._last_change.instant) @property - def last_update(self) -> dt_datetime: + def last_update(self) -> InstantView: """ :return: Timestamp of the last time when the item has been updated (read only) """ - return self._last_update.instant.to_system_tz().local().py_datetime() + return InstantView(self._last_update.instant) def __repr__(self) -> str: ret = '' diff --git a/src/HABApp/core/lib/__init__.py b/src/HABApp/core/lib/__init__.py index f2ed5876..54a8fce9 100644 --- a/src/HABApp/core/lib/__init__.py +++ b/src/HABApp/core/lib/__init__.py @@ -7,3 +7,4 @@ from .priority_list import PriorityList from .timeout import Timeout, TimeoutNotRunningError from .value_change import ValueChange +from .instant_view import InstantView diff --git a/src/HABApp/core/lib/instant_view.py b/src/HABApp/core/lib/instant_view.py new file mode 100644 index 00000000..cd0cba24 --- /dev/null +++ b/src/HABApp/core/lib/instant_view.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +from datetime import datetime as dt_datetime +from datetime import timedelta as dt_timedelta +from operator import ge, gt, le, lt +from typing import TYPE_CHECKING, Any, Final, TypeAlias, overload + +from whenever import Instant, TimeDelta + + +if TYPE_CHECKING: + from collections.abc import Callable + + +HINT_OBJ: TypeAlias = dt_timedelta | TimeDelta | int | str | Instant + + +class InstantView: + __slots__ = ('_instant',) + + def __init__(self, instant: Instant) -> None: + self._instant: Final = instant + + def delta(self, now: Instant | None = None) -> TimeDelta: + """Return the delta between the instant and now + + :param now: optional instant to compare to + """ + + if now is None: + now = Instant.now() + return now - self._instant + + def py_timedelta(self, now: Instant | None = None) -> dt_timedelta: + """Return the timedelta between the instant and now + + :param now: optional instant to compare to + """ + return self.delta(now).py_timedelta() + + def py_datetime(self) -> dt_datetime: + """Return the datetime of the instant""" + return self._instant.to_system_tz().local().py_datetime() + + def __repr__(self) -> str: + return f'InstantView({self._instant.to_system_tz()})' + + def _cmp(self, op: Callable[[Any, Any], bool], obj: HINT_OBJ | None, **kwargs: float) -> bool: + match obj: + case None: + if days := kwargs.get('days', 0): + kwargs['hours'] = kwargs.get('hours', 0) + days * 24 + td = TimeDelta(**kwargs) + case TimeDelta(): + td = obj + case dt_timedelta(): + td = TimeDelta.from_py_timedelta(obj) + case int(): + td = TimeDelta(seconds=obj) + case str(): + td = TimeDelta.parse_common_iso(obj) + case Instant(): + return op(self._instant, obj) + case _: + msg = f'Invalid type: {type(obj).__name__}' + raise TypeError(msg) + + if td <= TimeDelta.ZERO: + msg = 'Delta must be positive since instant is in the past' + raise ValueError(msg) + + return op(self._instant, Instant.now() - td) + + @overload + def older_than(self, *, days: float = 0, hours: float = 0, minutes: float = 0, seconds: float = 0) -> bool: ... + @overload + def older_than(self, obj: HINT_OBJ) -> bool: ... + + def older_than(self, obj=None, **kwargs): + """Check if the instant is older than the given value""" + return self._cmp(lt, obj, **kwargs) + + @overload + def newer_than(self, *, days: float = 0, hours: float = 0, minutes: float = 0, seconds: float = 0) -> bool: ... + @overload + def newer_than(self, obj: HINT_OBJ) -> bool: ... + + def newer_than(self, obj=None, **kwargs): + """Check if the instant is newer than the given value""" + return self._cmp(gt, obj, **kwargs) + + def __lt__(self, other: HINT_OBJ) -> bool: + return self._cmp(lt, other) + + def __le__(self, other: HINT_OBJ) -> bool: + return self._cmp(le, other) + + def __gt__(self, other: HINT_OBJ) -> bool: + return self._cmp(gt, other) + + def __ge__(self, other: HINT_OBJ) -> bool: + return self._cmp(ge, other) + + def __eq__(self, other: InstantView | Instant) -> bool: + if isinstance(other, InstantView): + return self._instant == other._instant + if isinstance(other, Instant): + return self._instant == other + return NotImplemented diff --git a/src/HABApp/openhab/connection/connection.py b/src/HABApp/openhab/connection/connection.py index eca3d786..23f5139b 100644 --- a/src/HABApp/openhab/connection/connection.py +++ b/src/HABApp/openhab/connection/connection.py @@ -8,7 +8,7 @@ import HABApp from HABApp.core.connections import AutoReconnectPlugin, BaseConnection, Connections, ConnectionStateToEventBusPlugin from HABApp.core.items.base_valueitem import datetime - +from HABApp.core.lib import InstantView if TYPE_CHECKING: from HABApp.openhab.items import OpenhabItem, Thing @@ -23,8 +23,8 @@ class OpenhabContext: # true when we waited during connect waited_for_openhab: bool - created_items: dict[str, tuple[OpenhabItem, datetime]] - created_things: dict[str, tuple[Thing, datetime]] + created_items: dict[str, tuple[OpenhabItem, InstantView]] + created_things: dict[str, tuple[Thing, InstantView]] session: aiohttp.ClientSession session_options: dict[str, Any] diff --git a/src/HABApp/openhab/connection/plugins/load_items.py b/src/HABApp/openhab/connection/plugins/load_items.py index dc09a417..32335fca 100644 --- a/src/HABApp/openhab/connection/plugins/load_items.py +++ b/src/HABApp/openhab/connection/plugins/load_items.py @@ -9,6 +9,7 @@ import HABApp.openhab.events from HABApp.core.connections import BaseConnectionPlugin from HABApp.core.internals import uses_item_registry +from HABApp.core.lib import InstantView from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext from HABApp.openhab.connection.handler import map_null_str from HABApp.openhab.connection.handler.func_async import async_get_all_items_state, async_get_items, async_get_things @@ -99,7 +100,7 @@ async def load_items(self, context: OpenhabContext) -> None: log.info(f'Updated {items_len:d} Items') - created_items: dict[str, tuple[OpenhabItem, datetime]] = { + created_items: dict[str, tuple[OpenhabItem, InstantView]] = { i.name: (i, i.last_update) for i in Items.get_items() if isinstance(i, OpenhabItem) } context.created_items.update(created_items) diff --git a/src/HABApp/rule/scheduler/__init__.py b/src/HABApp/rule/scheduler/__init__.py index 4fa74d2e..0c58d2b8 100644 --- a/src/HABApp/rule/scheduler/__init__.py +++ b/src/HABApp/rule/scheduler/__init__.py @@ -1,3 +1,4 @@ from eascheduler.builder import FilterBuilder as filter from eascheduler.builder import TriggerBuilder as trigger from eascheduler import add_holiday, get_holiday_name, get_holidays_by_name, get_sun_position, is_holiday, pop_holiday +from whenever import hours, minutes, seconds, milliseconds diff --git a/tests/test_core/test_items/item_tests.py b/tests/test_core/test_items/item_tests.py index be771999..4c72abe5 100644 --- a/tests/test_core/test_items/item_tests.py +++ b/tests/test_core/test_items/item_tests.py @@ -73,23 +73,23 @@ def test_time_value_change(self) -> None: def test_time_funcs(self): item = self.get_item() - now1 = datetime.now() + + now1 = Instant.now() time.sleep(0.000_001) item.set_value(self.ITEM_VALUES[0]) - # https://github.com/ariebovenberg/whenever/issues/171 time.sleep(0.000_001) - now2 = datetime.now() + now2 = Instant.now() time.sleep(0.000_001) assert now1 < item.last_change < now2, f'\n{now1}\n{item.last_change}\n{now2}' - assert now1 < item.last_update + assert now1 < item.last_update < now2, f'\n{now1}\n{item.last_update}\n{now2}' item.set_value(self.ITEM_VALUES[0]) time.sleep(0.000_001) - now3 = datetime.now() + now3 = Instant.now() assert now1 < item.last_change < now2 assert now2 < item.last_update < now3 diff --git a/tests/test_core/test_lib/test_instant_view.py b/tests/test_core/test_lib/test_instant_view.py new file mode 100644 index 00000000..9c2f67cc --- /dev/null +++ b/tests/test_core/test_lib/test_instant_view.py @@ -0,0 +1,54 @@ +from datetime import timedelta as dt_timedelta + +import pytest +from whenever import Instant, SystemDateTime, TimeDelta, patch_current_time, seconds + +from HABApp.core.items.base_valueitem import datetime +from HABApp.core.lib.instant_view import InstantView + + +@pytest.fixture +def view(): + now = Instant.now().subtract(minutes=1) + view = InstantView(now.subtract(minutes=1)) + + with patch_current_time(now, keep_ticking=False): + yield view + + +def test_methods(view: InstantView): + assert view < seconds(1) + assert not view < seconds(60) + assert view <= seconds(60) + + assert view > seconds(61) + assert not view > seconds(60) + assert view >= seconds(60) + + +def test_cmp_obj(view: InstantView): + assert view < TimeDelta(seconds=59) + assert view < dt_timedelta(seconds=59) + assert view < 'PT59S' + assert view < 59 + + +def test_cmp_funcs(view: InstantView): + assert view.older_than(seconds=59) + assert view.newer_than(seconds=61) + + +def test_delta_funcs(view: InstantView): + assert view.delta() == seconds(60) + assert view.py_timedelta() == dt_timedelta(seconds=60) + + +def test_convert(): + s = SystemDateTime(2021, 1, 2, 10, 11, 12) + view = InstantView(s.instant()) + assert view.py_datetime() == datetime(2021, 1, 2, 10, 11, 12) + + +def test_repr(): + view = InstantView(SystemDateTime(2021, 1, 2, 10, 11, 12).instant()) + assert str(view) == 'InstantView(2021-01-02T10:11:12+01:00)'