From a92eaffb28fbd5384b4082402287380c1705084a Mon Sep 17 00:00:00 2001 From: spacemanspiff2007 <10754716+spacemanspiff2007@users.noreply.github.com> Date: Fri, 22 Nov 2024 14:40:07 +0100 Subject: [PATCH] 24.11.0 (#459) --- .github/workflows/publish-pypi.yml | 4 +- .github/workflows/run-tox.yml | 4 +- .pre-commit-config.yaml | 19 +- .readthedocs.yml | 2 +- .ruff.toml | 126 ++++++--- docs/advanced_usage.rst | 2 +- docs/class_reference.rst | 65 +---- docs/conf.py | 16 +- docs/configuration.rst | 23 +- docs/getting_started.rst | 40 ++- docs/installation.rst | 2 + docs/interface_habapp.rst | 15 + docs/interface_mqtt.rst | 34 +++ docs/interface_openhab.rst | 7 +- docs/requirements.txt | 8 +- docs/rule.rst | 208 ++++++++++++-- docs/rule_examples.rst | 10 +- readme.md | 18 ++ requirements.txt | 7 +- requirements_setup.txt | 17 +- requirements_tests.txt | 6 +- run/conf/rules/async_rule.py | 4 +- run/conf/rules/logging_rule.py | 2 +- run/conf/rules/mqtt_rule.py | 6 +- run/conf/rules/openhab_rule.py | 8 +- run/conf/rules/openhab_things.py | 4 +- run/conf/rules/openhab_to_mqtt_rule.py | 4 +- run/conf/rules/time_rule.py | 10 +- run/conf_testing/config.yml | 13 + .../lib/HABAppTests/compare_values.py | 2 +- run/conf_testing/lib/HABAppTests/errors.py | 4 +- .../lib/HABAppTests/event_waiter.py | 16 +- .../lib/HABAppTests/item_waiter.py | 7 +- .../lib/HABAppTests/openhab_tmp_item.py | 25 +- .../HABAppTests/test_rule/_rest_patcher.py | 13 +- .../lib/HABAppTests/test_rule/_rule_ids.py | 6 +- .../test_rule/test_case/test_case.py | 4 +- .../test_rule/test_case/test_result.py | 11 +- .../lib/HABAppTests/test_rule/test_rule.py | 32 +-- run/conf_testing/lib/HABAppTests/utils.py | 4 +- .../rules/habapp/test_event_listener.py | 6 +- .../rules/habapp/test_group_listener.py | 8 +- run/conf_testing/rules/habapp/test_habapp.py | 20 +- .../rules/habapp/test_parameter_files.py | 4 +- .../rules/habapp/test_rule_context.py | 23 +- .../rules/habapp/test_scheduler.py | 63 +++-- .../rules/habapp/test_util_fade.py | 6 +- run/conf_testing/rules/habapp/test_utils.py | 6 +- .../rules/openhab/test_event_types.py | 6 +- run/conf_testing/rules/openhab/test_groups.py | 8 +- .../rules/openhab/test_habapp_internals.py | 4 +- .../rules/openhab/test_interface.py | 22 +- .../rules/openhab/test_interface_links.py | 16 +- .../rules/openhab/test_item_change.py | 4 +- .../rules/openhab/test_item_funcs.py | 32 ++- run/conf_testing/rules/openhab/test_items.py | 39 ++- run/conf_testing/rules/openhab/test_links.py | 8 +- .../rules/openhab/test_max_sse_msg_size.py | 20 +- .../rules/openhab/test_persistence.py | 39 +-- .../rules/openhab/test_scheduler.py | 99 +++++++ run/conf_testing/rules/openhab/test_things.py | 8 +- .../rules/openhab/test_transformations.py | 4 +- run/conf_testing/rules/openhab_bugs.py | 4 +- run/conf_testing/rules/test_mqtt.py | 20 +- setup.py | 33 ++- src/HABApp/__check_dependency_packages__.py | 4 +- src/HABApp/__cmd_args__.py | 3 +- src/HABApp/__debug_info__.py | 2 +- src/HABApp/__main__.py | 7 +- src/HABApp/__splash_screen__.py | 2 +- src/HABApp/__version__.py | 2 +- src/HABApp/config/debug.py | 92 ++++++ src/HABApp/config/loader.py | 58 ++-- src/HABApp/config/logging/__init__.py | 3 +- src/HABApp/config/logging/buffered_logger.py | 9 +- src/HABApp/config/logging/config.py | 196 +++++++------ src/HABApp/config/logging/handler.py | 12 +- src/HABApp/config/logging/queue_handler.py | 15 +- src/HABApp/config/logging/utils.py | 25 ++ src/HABApp/config/models/directories.py | 11 +- src/HABApp/config/models/habapp.py | 59 +++- src/HABApp/config/models/location.py | 11 +- src/HABApp/config/models/mqtt.py | 48 +++- src/HABApp/config/models/openhab.py | 3 +- src/HABApp/config/platform_defaults.py | 5 +- src/HABApp/core/__init__.py | 4 +- src/HABApp/core/asyncio.py | 37 +-- .../core/connections/base_connection.py | 32 ++- src/HABApp/core/connections/base_plugin.py | 13 +- .../core/connections/connection_task.py | 7 +- src/HABApp/core/connections/manager.py | 8 +- .../core/connections/plugin_callback.py | 3 +- .../connections/plugins/auto_reconnect.py | 14 +- .../connections/plugins/state_to_event.py | 8 +- .../core/connections/status_transitions.py | 12 +- src/HABApp/core/const/const.py | 10 +- src/HABApp/core/const/hints.py | 16 +- src/HABApp/core/const/json.py | 9 +- src/HABApp/core/const/topics.py | 4 +- src/HABApp/core/errors.py | 4 +- src/HABApp/core/events/__init__.py | 21 +- src/HABApp/core/events/events.py | 55 ++-- src/HABApp/core/events/filter/__init__.py | 6 +- src/HABApp/core/events/filter/event.py | 8 +- src/HABApp/core/events/filter/groups.py | 8 +- .../core/events/filter/habapp_events.py | 11 +- src/HABApp/core/events/habapp_events.py | 12 +- src/HABApp/core/files/errors.py | 10 +- src/HABApp/core/files/file/file.py | 9 +- src/HABApp/core/files/file/file_state.py | 2 +- src/HABApp/core/files/file/file_types.py | 2 +- src/HABApp/core/files/file/properties.py | 5 +- src/HABApp/core/files/folders/folders.py | 14 +- src/HABApp/core/files/manager/files.py | 6 +- .../core/files/manager/listen_events.py | 5 +- src/HABApp/core/files/manager/worker.py | 5 +- src/HABApp/core/files/setup.py | 2 +- src/HABApp/core/files/watcher/base_watcher.py | 9 +- src/HABApp/core/files/watcher/file_watcher.py | 13 +- .../core/files/watcher/folder_watcher.py | 16 +- src/HABApp/core/internals/__init__.py | 13 +- src/HABApp/core/internals/context/context.py | 17 +- .../core/internals/context/get_context.py | 13 +- .../core/internals/event_bus/base_listener.py | 14 +- .../core/internals/event_bus/event_bus.py | 45 +-- .../core/internals/event_bus_listener.py | 39 +-- src/HABApp/core/internals/event_filter.py | 8 +- .../internals/item_registry/item_registry.py | 18 +- .../item_registry/item_registry_item.py | 10 +- src/HABApp/core/internals/proxy/proxies.py | 3 +- src/HABApp/core/internals/proxy/proxy_obj.py | 23 +- .../internals/wrapped_function/__init__.py | 4 +- .../core/internals/wrapped_function/base.py | 38 ++- .../wrapped_function/wrapped_async.py | 33 ++- .../wrapped_function/wrapped_sync.py | 25 +- .../wrapped_function/wrapped_thread.py | 129 ++++----- .../internals/wrapped_function/wrapper.py | 47 ++-- src/HABApp/core/items/__init__.py | 5 +- src/HABApp/core/items/base_item.py | 50 ++-- src/HABApp/core/items/base_item_times.py | 21 +- src/HABApp/core/items/base_item_watch.py | 21 +- src/HABApp/core/items/base_valueitem.py | 50 ++-- src/HABApp/core/items/item_aggregation.py | 25 +- src/HABApp/core/items/item_color.py | 9 +- src/HABApp/core/items/tmp_data.py | 8 +- src/HABApp/core/lib/__init__.py | 8 +- src/HABApp/core/lib/exceptions/format.py | 21 +- .../core/lib/exceptions/format_frame.py | 3 +- .../core/lib/exceptions/format_frame_vars.py | 48 +++- src/HABApp/core/lib/funcs.py | 7 +- src/HABApp/core/lib/instant_view.py | 140 ++++++++++ src/HABApp/core/lib/parameters/__init__.py | 1 - .../core/lib/parameters/positive_time_diff.py | 22 -- src/HABApp/core/lib/pending_future.py | 12 +- src/HABApp/core/lib/priority_list.py | 21 +- src/HABApp/core/lib/rgb_hsv.py | 7 +- src/HABApp/core/lib/single_task.py | 13 +- src/HABApp/core/lib/timeout.py | 4 +- src/HABApp/core/lib/value_change.py | 4 +- src/HABApp/core/logger.py | 20 +- src/HABApp/core/shutdown.py | 122 ++++++++ src/HABApp/core/types/color.py | 31 ++- src/HABApp/core/wrapper.py | 22 +- src/HABApp/mqtt/__init__.py | 5 +- src/HABApp/mqtt/connection/connection.py | 22 +- src/HABApp/mqtt/connection/handler.py | 6 +- src/HABApp/mqtt/connection/publish.py | 31 ++- src/HABApp/mqtt/connection/subscribe.py | 25 +- src/HABApp/mqtt/events/mqtt_filters.py | 4 +- src/HABApp/mqtt/items/mqtt_item.py | 14 +- src/HABApp/mqtt/items/mqtt_pair_item.py | 9 +- src/HABApp/mqtt/mqtt_interface.py | 99 ------- src/HABApp/mqtt/mqtt_payload.py | 4 +- src/HABApp/mqtt/util/__init__.py | 1 + src/HABApp/mqtt/util/publish_options.py | 59 ++++ src/HABApp/openhab/connection/connection.py | 20 +- .../openhab/connection/handler/func_async.py | 59 ++-- .../openhab/connection/handler/func_sync.py | 2 +- .../openhab/connection/handler/handler.py | 13 +- .../openhab/connection/handler/helper.py | 27 +- .../openhab/connection/plugins/events_sse.py | 8 +- .../openhab/connection/plugins/load_items.py | 15 +- src/HABApp/openhab/connection/plugins/out.py | 16 +- .../plugins/overview_broken_links.py | 2 +- .../connection/plugins/overview_things.py | 2 +- src/HABApp/openhab/connection/plugins/ping.py | 6 +- .../plugins/plugin_things/cfg_validator.py | 51 ++-- .../plugin_things/file_writer/formatter.py | 21 +- .../file_writer/formatter_builder.py | 9 +- .../plugin_things/file_writer/writer.py | 14 +- .../plugins/plugin_things/filters.py | 17 +- .../plugins/plugin_things/item_worker.py | 13 +- .../plugins/plugin_things/plugin_things.py | 8 +- .../plugins/plugin_things/str_builder.py | 12 +- .../plugins/plugin_things/thing_config.py | 8 +- .../plugins/plugin_things/thing_worker.py | 3 +- .../connection/plugins/wait_for_restore.py | 4 +- .../connection/plugins/wait_for_startlevel.py | 7 +- .../openhab/definitions/helpers/log_table.py | 30 +- .../definitions/helpers/persistence_data.py | 12 +- .../openhab/definitions/rest/__init__.py | 11 +- .../openhab/definitions/rest/habapp_data.py | 8 +- src/HABApp/openhab/definitions/rest/items.py | 72 ++--- src/HABApp/openhab/definitions/rest/links.py | 15 +- .../openhab/definitions/rest/persistence.py | 23 +- src/HABApp/openhab/definitions/rest/root.py | 18 +- .../openhab/definitions/rest/systeminfo.py | 26 +- src/HABApp/openhab/definitions/rest/things.py | 51 ++-- .../definitions/rest/transformations.py | 11 +- src/HABApp/openhab/definitions/values.py | 36 +-- src/HABApp/openhab/events/channel_events.py | 8 +- src/HABApp/openhab/events/event_filters.py | 8 +- src/HABApp/openhab/events/item_events.py | 88 +++--- src/HABApp/openhab/events/thing_events.py | 44 +-- src/HABApp/openhab/item_to_reg.py | 2 +- src/HABApp/openhab/items/base_item.py | 44 +-- src/HABApp/openhab/items/color_item.py | 31 +-- src/HABApp/openhab/items/commands.py | 13 +- src/HABApp/openhab/items/contact_item.py | 11 +- src/HABApp/openhab/items/datetime_item.py | 11 +- src/HABApp/openhab/items/dimmer_item.py | 16 +- src/HABApp/openhab/items/group_item.py | 13 +- src/HABApp/openhab/items/image_item.py | 34 ++- src/HABApp/openhab/items/number_item.py | 17 +- .../openhab/items/rollershutter_item.py | 14 +- src/HABApp/openhab/items/string_item.py | 16 +- src/HABApp/openhab/items/switch_item.py | 14 +- src/HABApp/openhab/items/thing_item.py | 10 +- src/HABApp/openhab/items/tuple_items.py | 22 +- src/HABApp/openhab/map_events.py | 4 +- src/HABApp/openhab/map_items.py | 16 +- src/HABApp/openhab/process_events.py | 3 +- .../openhab/transformations/_map/classes.py | 4 +- .../openhab/transformations/_map/registry.py | 12 +- src/HABApp/openhab/transformations/base.py | 16 +- src/HABApp/parameters/parameter.py | 24 +- src/HABApp/parameters/parameter_files.py | 6 +- src/HABApp/parameters/parameters.py | 4 +- src/HABApp/rule/__init__.py | 1 + src/HABApp/rule/interfaces/__init__.py | 2 +- src/HABApp/rule/interfaces/_http.py | 19 +- .../{http_interface.py => interface_http.py} | 0 src/HABApp/rule/interfaces/rule_subprocess.py | 25 +- src/HABApp/rule/rule.py | 71 +++-- src/HABApp/rule/rule_hook.py | 22 +- src/HABApp/rule/scheduler/__init__.py | 7 +- src/HABApp/rule/scheduler/executor.py | 14 - .../rule/scheduler/habappschedulerview.py | 113 -------- src/HABApp/rule/scheduler/job_builder.py | 262 ++++++++++++++++++ src/HABApp/rule/scheduler/job_ctrl.py | 130 +++++++++ src/HABApp/rule/scheduler/jobs.py | 120 -------- src/HABApp/rule/scheduler/scheduler.py | 69 ----- src/HABApp/rule_ctx/rule_ctx.py | 34 ++- .../rule_manager/benchmark/bench_base.py | 22 +- .../rule_manager/benchmark/bench_file.py | 4 +- .../rule_manager/benchmark/bench_habapp.py | 16 +- .../rule_manager/benchmark/bench_mqtt.py | 16 +- src/HABApp/rule_manager/benchmark/bench_oh.py | 22 +- .../rule_manager/benchmark/bench_times.py | 13 +- src/HABApp/rule_manager/rule_file.py | 6 +- src/HABApp/rule_manager/rule_manager.py | 24 +- src/HABApp/runtime/__init__.py | 1 - src/HABApp/runtime/runtime.py | 13 +- src/HABApp/runtime/shutdown.py | 81 ------ src/HABApp/util/fade/fade.py | 18 +- .../util/listener_groups/listener_creator.py | 23 +- .../util/listener_groups/listener_groups.py | 56 ++-- src/HABApp/util/multimode/item.py | 14 +- src/HABApp/util/multimode/mode_base.py | 10 +- src/HABApp/util/multimode/mode_switch.py | 7 +- src/HABApp/util/multimode/mode_value.py | 16 +- src/HABApp/util/rate_limiter/limiter.py | 17 +- src/HABApp/util/rate_limiter/limits/base.py | 4 +- .../util/rate_limiter/limits/fixed_window.py | 10 +- .../util/rate_limiter/limits/leaky_bucket.py | 6 +- src/HABApp/util/rate_limiter/parser.py | 3 +- src/HABApp/util/statistics.py | 8 +- src/HABApp/util/threshold.py | 4 +- tests/conftest.py | 2 +- tests/helpers/event_bus.py | 6 +- tests/helpers/inspect/classes.py | 13 +- tests/helpers/inspect/docstr.py | 28 +- tests/helpers/inspect/habapp.py | 28 +- tests/helpers/inspect/module.py | 8 +- tests/helpers/inspect/signature.py | 13 +- tests/helpers/log/log_collector.py | 31 ++- tests/helpers/log/log_matcher.py | 35 +-- tests/helpers/log/log_utils.py | 4 +- tests/helpers/mock_file.py | 11 +- tests/helpers/mock_monotonic.py | 2 +- tests/helpers/parent_rule.py | 2 +- tests/helpers/sync_worker.py | 4 +- tests/helpers/traceback.py | 2 +- tests/rule_runner/rule_runner.py | 60 ++-- tests/rule_runner/test_rule_runner.py | 88 ++++-- tests/test_all/test_items.py | 6 +- tests/test_cmd_args.py | 4 +- tests/test_config/test_config.py | 89 ++++++ tests/test_config/test_logging.py | 84 ++++++ tests/test_config/test_platform.py | 6 +- tests/test_core/test_connections.py | 25 +- tests/test_core/test_context.py | 2 +- tests/test_core/test_event_bus.py | 38 ++- .../test_events/test_core_filters.py | 18 +- .../test_files/test_file_dependencies.py | 25 +- .../test_files/test_file_properties.py | 16 +- tests/test_core/test_files/test_rel_name.py | 10 +- tests/test_core/test_files/test_watcher.py | 4 +- tests/test_core/test_item_registry.py | 18 +- tests/test_core/test_item_watch.py | 6 +- tests/test_core/test_items/__init__.py | 2 +- tests/test_core/test_items/item_tests.py | 119 ++++++++ tests/test_core/test_items/test_item.py | 45 +-- .../test_items/test_item_aggregation.py | 8 +- tests/test_core/test_items/test_item_color.py | 14 +- .../test_items/test_item_interface.py | 4 +- tests/test_core/test_items/test_item_times.py | 41 ++- tests/test_core/test_items/test_item_value.py | 10 +- tests/test_core/test_items/tests_all_items.py | 67 ----- tests/test_core/test_lib/test_compare.py | 6 +- .../test_lib/test_format_traceback.py | 196 ++++++++++--- tests/test_core/test_lib/test_instant_view.py | 71 +++++ tests/test_core/test_lib/test_single_task.py | 4 +- tests/test_core/test_lib/test_timeout.py | 10 +- tests/test_core/test_lib/test_value_change.py | 6 +- tests/test_core/test_logger.py | 6 +- tests/test_core/test_types/test_color.py | 14 +- tests/test_core/test_utilities.py | 8 +- tests/test_core/test_wrapped_func.py | 61 +++- tests/test_core/test_wrapper.py | 12 +- tests/test_debug_info.py | 2 +- tests/test_docs.py | 4 +- tests/test_mqtt/test_interface.py | 2 +- tests/test_mqtt/test_mqtt_filters.py | 4 +- tests/test_mqtt/test_mqtt_pair_item.py | 2 +- tests/test_mqtt/test_retain.py | 4 +- tests/test_mqtt/test_values.py | 2 +- .../test_events/test_from_dict.py | 42 +-- .../test_events/test_oh_filters.py | 4 +- tests/test_openhab/test_helpers/test_table.py | 6 +- tests/test_openhab/test_interface_sync.py | 6 +- tests/test_openhab/test_item_to_reg.py | 2 +- tests/test_openhab/test_items/test_all.py | 22 +- tests/test_openhab/test_items/test_call.py | 4 +- .../test_openhab/test_items/test_commands.py | 8 +- tests/test_openhab/test_items/test_contact.py | 4 +- tests/test_openhab/test_items/test_dimmer.py | 4 +- .../test_items/test_group_handling.py | 2 +- tests/test_openhab/test_items/test_image.py | 2 +- tests/test_openhab/test_items/test_mapping.py | 15 +- tests/test_openhab/test_items/test_number.py | 6 +- .../test_items/test_rollershutter.py | 2 +- tests/test_openhab/test_items/test_switch.py | 4 +- tests/test_openhab/test_items/test_thing.py | 209 +++++++------- tests/test_openhab/test_openhab_datatypes.py | 20 +- .../test_plugins/test_broken_links.py | 22 +- .../test_plugins/test_load_items.py | 27 +- .../test_plugins/test_thing/test_errors.py | 2 +- .../test_thing/test_file_format.py | 12 +- .../test_file_writer/test_builder.py | 8 +- .../test_file_writer/test_formatter.py | 8 +- .../test_file_writer/test_writer.py | 12 +- .../test_plugins/test_thing/test_filter.py | 4 +- .../test_thing/test_str_builder.py | 6 +- .../test_plugins/test_thing/test_thing_cfg.py | 10 +- tests/test_openhab/test_rest/test_grp_func.py | 17 +- tests/test_openhab/test_rest/test_items.py | 32 +-- tests/test_openhab/test_rest/test_links.py | 10 +- tests/test_openhab/test_rest/test_things.py | 16 +- .../test_rest/test_value_convert.py | 2 +- .../test_transformations/test_base.py | 2 +- .../test_transformations/test_map.py | 10 +- tests/test_openhab/test_values.py | 8 +- tests/test_packages.py | 4 +- tests/test_parameters/test_base.py | 8 +- tests/test_parameters/test_dict_parameter.py | 6 +- tests/test_parameters/test_parameter.py | 6 +- tests/test_rule/test_hook.py | 2 +- tests/test_rule/test_item_search.py | 8 +- tests/test_rule/test_process.py | 24 +- tests/test_rule/test_rule_factory.py | 4 +- tests/test_rule/test_rule_funcs.py | 8 +- tests/test_utils/test_fade.py | 4 +- tests/test_utils/test_functions.py | 10 +- tests/test_utils/test_listener_groups.py | 14 +- tests/test_utils/test_multivalue.py | 40 ++- tests/test_utils/test_rate_limiter.py | 20 +- tests/test_utils/test_statistics.py | 4 +- tests/test_utils/test_threshold.py | 6 +- tools/prettify_json.py | 6 +- tox.ini | 25 +- 391 files changed, 4908 insertions(+), 3461 deletions(-) create mode 100644 run/conf_testing/rules/openhab/test_scheduler.py create mode 100644 src/HABApp/config/debug.py create mode 100644 src/HABApp/config/logging/utils.py create mode 100644 src/HABApp/core/lib/instant_view.py delete mode 100644 src/HABApp/core/lib/parameters/__init__.py delete mode 100644 src/HABApp/core/lib/parameters/positive_time_diff.py create mode 100644 src/HABApp/core/shutdown.py delete mode 100644 src/HABApp/mqtt/mqtt_interface.py create mode 100644 src/HABApp/mqtt/util/__init__.py create mode 100644 src/HABApp/mqtt/util/publish_options.py rename src/HABApp/rule/interfaces/{http_interface.py => interface_http.py} (100%) delete mode 100644 src/HABApp/rule/scheduler/executor.py delete mode 100644 src/HABApp/rule/scheduler/habappschedulerview.py create mode 100644 src/HABApp/rule/scheduler/job_builder.py create mode 100644 src/HABApp/rule/scheduler/job_ctrl.py delete mode 100644 src/HABApp/rule/scheduler/jobs.py delete mode 100644 src/HABApp/rule/scheduler/scheduler.py delete mode 100644 src/HABApp/runtime/shutdown.py create mode 100644 tests/test_config/test_config.py create mode 100644 tests/test_config/test_logging.py create mode 100644 tests/test_core/test_items/item_tests.py delete mode 100644 tests/test_core/test_items/tests_all_items.py create mode 100644 tests/test_core/test_lib/test_instant_view.py diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml index b5eef206..8c252fa8 100644 --- a/.github/workflows/publish-pypi.yml +++ b/.github/workflows/publish-pypi.yml @@ -17,10 +17,10 @@ jobs: with: ref: master - - name: Set up Python 3.10 + - name: Set up Python 3.12 uses: actions/setup-python@v5 with: - python-version: '3.10' + python-version: '3.12' - name: Install setuptools run: | diff --git a/.github/workflows/run-tox.yml b/.github/workflows/run-tox.yml index 888dc09c..554be9e0 100644 --- a/.github/workflows/run-tox.yml +++ b/.github/workflows/run-tox.yml @@ -10,7 +10,7 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: - python-version: '3.11' + python-version: '3.12' - uses: pre-commit/action@v3.0.1 @@ -20,7 +20,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ['3.10', '3.11', '3.12', '3.13'] steps: - uses: actions/checkout@v4 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 290169f6..330fbc09 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.6.0 + rev: v5.0.0 hooks: - id: check-ast - id: check-builtin-literals @@ -15,14 +15,27 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.5.0 + rev: v0.8.0 hooks: + - id: ruff + name: ruff unused imports + # F401 [*] {name} imported but unused + args: [ "--select", "F401,I001", "--extend-exclude", "__init__.py", "--fix"] + - id: ruff # I001 [*] Import block is un-sorted or un-formatted # UP035 [*] Import from {target} instead: {names} # Q000 [*] Double quote found but single quotes preferred # Q001 [*] Double quote multiline found but single quotes preferred - args: [ "--select", "I001,UP035,Q000,Q001", "--fix"] + args: [ "--select", "UP035,Q000,Q001", "--fix"] + + + - repo: https://github.com/JelleZijlstra/autotyping + rev: 24.9.0 + hooks: + - id: autotyping + types: [python] + args: [--safe] - repo: https://github.com/pre-commit/pygrep-hooks diff --git a/.readthedocs.yml b/.readthedocs.yml index d26630e8..ad6f8928 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -19,7 +19,7 @@ formats: all build: os: ubuntu-22.04 tools: - python: "3.10" + python: "3.12" apt_packages: - graphviz diff --git a/.ruff.toml b/.ruff.toml index 37712318..83f9d0bc 100644 --- a/.ruff.toml +++ b/.ruff.toml @@ -1,64 +1,78 @@ - -line-length = 120 indent-width = 4 +line-length = 120 -target-version = "py38" -src = ["src", "test"] - +target-version = "py310" -extend-exclude = [ - "__init__.py", - "src/__test_*.py" +src = [ + "src", + "tests" ] +extend-exclude = ["src/__test_*.py"] + [lint] -select = [ - "E", "W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w - "I", # https://docs.astral.sh/ruff/rules/#isort-i - "UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up - - "A", # https://docs.astral.sh/ruff/rules/#flake8-builtins-a - "ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async - "C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4 - "EM", # https://docs.astral.sh/ruff/rules/#flake8-errmsg-em - "FIX", # https://docs.astral.sh/ruff/rules/#flake8-fixme-fix - "INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp - "ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc - "PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie - "PT", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt - "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth - "RET", # https://docs.astral.sh/ruff/rules/#flake8-return-ret - "SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim - "SLOT", # https://docs.astral.sh/ruff/rules/#flake8-slots-slot - "T10", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10 - "TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch - "TD", # https://docs.astral.sh/ruff/rules/#flake8-todos-td - - "TRY", # https://docs.astral.sh/ruff/rules/#tryceratops-try - "FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly - "PERF", # https://docs.astral.sh/ruff/rules/#perflint-perf - "RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf - - # "PL", # https://docs.astral.sh/ruff/rules/#pylint-pl - # "FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb -] +select = ["ALL"] ignore = [ + "D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d + "T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20 + "DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz + "SLF", # https://docs.astral.sh/ruff/rules/#flake8-self-slf + "RET501", # https://docs.astral.sh/ruff/rules/unnecessary-return-none/#unnecessary-return-none-ret501 "TRY400", # https://docs.astral.sh/ruff/rules/error-instead-of-exception/ - "A003", # https://docs.astral.sh/ruff/rules/builtin-attribute-shadowing/ + # https://docs.astral.sh/ruff/rules/#flake8-builtins-a + "A003", # Python builtin is shadowed by class attribute {name} from {row} + + # https://docs.astral.sh/ruff/rules/#pyflakes-f + "F401", # {name} imported but unused; consider using importlib.util.find_spec to test for availability + + # https://docs.astral.sh/ruff/rules/#flake8-bandit-s + "S311", # Standard pseudo-random generators are not suitable for cryptographic purposes + + # https://docs.astral.sh/ruff/rules/#pyupgrade-up + "UP038", # Use X | Y in {} call instead of (X, Y) + + # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann + "ANN101", # Missing type annotation for {name} in method + "ANN102", # Missing type annotation for {name} in classmethod + "ANN401", # Dynamically typed expressions (typing.Any) are disallowed in {name} + + # https://docs.astral.sh/ruff/rules/#flake8-blind-except-ble + "BLE001", # Do not catch blind exception: {name} + + # https://docs.astral.sh/ruff/rules/#flake8-raise-rse + "RSE102", # Unnecessary parentheses on raised exception + + # https://docs.astral.sh/ruff/rules/#flake8-commas-com + "COM812", # Trailing comma missing + "COM819", # Trailing comma prohibited + + # https://docs.astral.sh/ruff/rules/#warning-w_1 + "PLW0603", # Using the global statement to update {name} is discouraged + + # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g + "G004", # Logging statement uses f-string + + # https://docs.astral.sh/ruff/rules/#refactor-r + "PLR1711", # Useless return statement at end of function + + # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf + "RUF005", # Consider {expression} instead of concatenation + + # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt + "PT007", # Wrong values type in @pytest.mark.parametrize expected {values} of {row} ] [format] -# Use single quotes for non-triple-quoted strings. quote-style = "single" # https://docs.astral.sh/ruff/settings/#lintflake8-quotes [lint.flake8-quotes] -inline-quotes = "single" +inline-quotes = "single" multiline-quotes = "single" @@ -66,12 +80,34 @@ multiline-quotes = "single" builtins-ignorelist = ["id", "input"] +# https://docs.astral.sh/ruff/settings/#lintisort +[lint.isort] +lines-after-imports = 2 # https://docs.astral.sh/ruff/settings/#lint_isort_lines-after-imports + + [lint.per-file-ignores] -"docs/conf.py" = ["INP001", "A001"] +"docs/conf.py" = [ + "INP001", # File `conf.py` is part of an implicit namespace package. Add an `__init__.py`. + "A001", # Variable `copyright` is shadowing a Python builtin + "PTH118", # `os.path.join()` should be replaced by `Path` with `/` operator + "PTH100", # `os.path.abspath()` should be replaced by `Path.resolve()` +] + "setup.py" = ["PTH123"] + "run/**" = ["INP001"] +"tests/*" = [ + "ANN", # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann -[lint.isort] -# https://docs.astral.sh/ruff/settings/#isort-lines-after-imports -lines-after-imports = 2 + # https://docs.astral.sh/ruff/rules/#flake8-bandit-s + "S101", # Use of assert detected + + # https://docs.astral.sh/ruff/rules/#refactor-r + "PLR2004", # Magic value used in comparison, consider replacing {value} with a constant variable + "PLR0913", # Too many arguments in function definition ({c_args} > {max_args}) +] + +"interface_*.py" = [ + "F401" # F401 [*] {name} imported but unused +] diff --git a/docs/advanced_usage.rst b/docs/advanced_usage.rst index d6ea1c9c..9837b4ec 100644 --- a/docs/advanced_usage.rst +++ b/docs/advanced_usage.rst @@ -88,7 +88,7 @@ Example # depends on: # - rules/rule_file.py # reloads on: - # - params/param_file.yml + # - param/param_file.yml import HABApp ... diff --git a/docs/class_reference.rst b/docs/class_reference.rst index b9846e13..37732935 100644 --- a/docs/class_reference.rst +++ b/docs/class_reference.rst @@ -26,70 +26,11 @@ ItemNoChangeWatch :member-order: groupwise -Scheduler -====================================== - - -OneTimeJob -"""""""""""""""""""""""""""""""""""""" - -.. autoclass:: HABApp.rule.scheduler.jobs.OneTimeJob - :members: - :inherited-members: - :member-order: groupwise - -CountdownJob -"""""""""""""""""""""""""""""""""""""" - -.. autoclass:: HABApp.rule.scheduler.jobs.CountdownJob - :members: - :inherited-members: - :member-order: groupwise - -ReoccurringJob -"""""""""""""""""""""""""""""""""""""" - -.. autoclass:: HABApp.rule.scheduler.jobs.ReoccurringJob - :members: - :inherited-members: - :member-order: groupwise - -DayOfWeekJob -"""""""""""""""""""""""""""""""""""""" - -.. autoclass:: HABApp.rule.scheduler.jobs.DayOfWeekJob - :members: - :inherited-members: - :member-order: groupwise - -DawnJob -"""""""""""""""""""""""""""""""""""""" - -.. autoclass:: HABApp.rule.scheduler.jobs.DawnJob - :members: - :inherited-members: - :member-order: groupwise -SunriseJob -"""""""""""""""""""""""""""""""""""""" - -.. autoclass:: HABApp.rule.scheduler.jobs.SunriseJob - :members: - :inherited-members: - :member-order: groupwise - -SunsetJob -"""""""""""""""""""""""""""""""""""""" - -.. autoclass:: HABApp.rule.scheduler.jobs.SunsetJob - :members: - :inherited-members: - :member-order: groupwise - -DuskJob -"""""""""""""""""""""""""""""""""""""" +InstantView +====================================== -.. autoclass:: HABApp.rule.scheduler.jobs.DuskJob +.. autoclass:: HABApp.core.lib.InstantView :members: :inherited-members: :member-order: groupwise diff --git a/docs/conf.py b/docs/conf.py index a9acc2fe..526958ae 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -26,7 +26,7 @@ logger_lvl = logging.DEBUG if IS_RTD_BUILD or IS_CI else logging.INFO # set level to DEBUG for CI -def log(msg: str): +def log(msg: str) -> None: sphinx_logger.log(logger_lvl, f'[POST] {msg:s}') @@ -87,7 +87,7 @@ def log(msg: str): # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' +source_suffix = {'.rst': 'restructuredtext'} # The master toctree document. master_doc = 'index' @@ -123,7 +123,6 @@ def log(msg: str): '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': '', @@ -233,7 +232,7 @@ def log(msg: str): nitpick_ignore_regex = [ (re.compile(r'py:data|py:class'), re.compile(r'typing\..+')), - (re.compile(r'py:class'), re.compile(r'(?:datetime|pendulum|aiohttp|pathlib)\..+')) + (re.compile(r'py:class'), re.compile(r'(?:datetime|aiohttp|pathlib)\..+')) ] # -- Extension configuration ------------------------------------------------- @@ -295,7 +294,7 @@ def replace_node_contents(node: Node): replacement = TYPE_REPLACEMENTS.get(node_text) # https://www.sphinx-doc.org/en/master/extdev/nodes.html - if isinstance(node, desc_signature) and node.attributes.get('fullname', '').endswith('Item'): + if isinstance(node, desc_signature) and node.attributes.get('fullname', '').endswith(('Item', 'JobControl')): log(f'Removing constructor signature of {", ".join(node.attributes["ids"])}') assert len(node.children) == 3 signature_node = node.children[2] @@ -323,14 +322,14 @@ def replace_node_contents(node: Node): return matched_nodes -def transform_desc(app, domain, objtype: str, contentnode): +def transform_desc(app, domain, objtype: str, contentnode) -> None: # if objtype != 'pydantic_field': # return None replace_node_contents(node=contentnode.parent) -def setup(app): +def setup(app) -> None: app.connect('object-description-transform', transform_desc) @@ -338,7 +337,8 @@ def setup(app): # 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/configuration.rst b/docs/configuration.rst index 03e6546b..6f04ea82 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -28,10 +28,17 @@ Example # (!) Attention (!): # Don't create rule instances in files inside the lib folder! It will lead to strange behaviour. - location: # Specify the location where your HABApp instance is running - latitude: 0.0 # The value is used to calculate the Sunrise/Sunset etc accordingly - longitude: 0.0 - elevation: 0.0 + + # Specify the location where your HABApp instance is running + location: + # The coordinates are used to calculate the Sunrise/Sunset etc + latitude: 0.0 + longitude: 0.0 + elevation: 0.0 + # The country and optional subdivision is used to calculate the holidays + country: DE # ISO 3166-1 Alpha-2 country code - here Germany + subdivision: BE # ISO 3166-2 Subdivision code or alias - here Berlin + openhab: ping: @@ -179,3 +186,11 @@ Logging ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. autopydantic_model:: LoggingConfig + +Debug +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autopydantic_model:: DebugConfig + +.. autopydantic_model:: PeriodicTracebackDumpConfig + +.. autopydantic_model:: WatchEventLoopConfig diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 2428556d..8eee6d21 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 pendulum import DateTime + 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._last_update.dt = DateTime(2022, 8, 20, 12, 16) - item._last_change.dt = DateTime(2022, 8, 20, 10, 30) + 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, 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, InstantView class TimestampRule(HABApp.Rule): def __init__(self): @@ -196,13 +202,33 @@ All items have two additional timestamps set which can be used to simplify rule # This item was created by another rule, that's why "get_item" is used 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}') + # Access of item timestamps + + # 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') + + # if you want to do calculations you can also get a delta + delta_to_now = self.my_item.last_change.delta_now() + + + # Instead of dealing with timestamps you can also have the same convenience + # for arbitrary timestamps by using an InstantView object + timestamp = InstantView.now() + assert timestamp.newer_than(minutes=1) + delta_to_now = timestamp.delta_now() + TimestampRule() # ------------ hide: start ------------ + p.__exit__(None, None, None) runner.tear_down() # ------------ hide: stop ------------- diff --git a/docs/installation.rst b/docs/installation.rst index 7b85c7bb..5f5c6731 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -120,6 +120,8 @@ If your installation is not done in "/opt/habapp/bin" replace accordingly as wel Type=simple User=openhab Group=openhab + Restart=on-failure + RestartSec=10min UMask=002 ExecStart=/opt/habapp/bin/habapp -c PATH_TO_CONFIGURATION_FOLDER diff --git a/docs/interface_habapp.rst b/docs/interface_habapp.rst index 27c00785..a51a7b4c 100644 --- a/docs/interface_habapp.rst +++ b/docs/interface_habapp.rst @@ -185,6 +185,21 @@ This event gets emitted every time a value of an item changes :inherited-members: +ValueCommandEvent +====================================== + +This event indicates that the item should change to a new value. + +.. inheritance-diagram:: HABApp.core.events.ValueCommandEvent + :parts: 1 + +.. autoclass:: HABApp.core.events.ValueCommandEvent + :members: + :inherited-members: + + + + ItemNoUpdateEvent ====================================== diff --git a/docs/interface_mqtt.rst b/docs/interface_mqtt.rst index ca856750..6aeb2230 100644 --- a/docs/interface_mqtt.rst +++ b/docs/interface_mqtt.rst @@ -162,6 +162,40 @@ and it will also trigger for :class:`~HABApp.mqtt.events.MqttValueChangeEvent`. :member-order: groupwise +Mqtt util +-------------------------------------- + +MqttPublishOptions +"""""""""""""""""""""""""""""""""""""" + +.. autoclass:: HABApp.mqtt.util.MqttPublishOptions + :members: + :inherited-members: + :member-order: groupwise + +.. exec_code:: + :hide_output: + + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + SimpleRuleRunner().set_up() + + import HABApp + from unittest.mock import MagicMock + HABApp.mqtt.util.publish_options.publish = MagicMock() + # ------------ hide: stop ------------- + from HABApp.mqtt.util import MqttPublishOptions + + topic = MqttPublishOptions('my/output/only/topic') + topic.publish('new_value') + + topic_qos = MqttPublishOptions('my/output/only/topic', qos=2) + topic_qos.publish('new_value') + + # create new through replace command wich will use qos=2 and retain=True + topic_qos_retain = topic_qos.replace(retain=True) + topic_qos_retain.publish('new_value') + Example MQTT rule -------------------------------------- diff --git a/docs/interface_openhab.rst b/docs/interface_openhab.rst index 28211ac7..c053c263 100644 --- a/docs/interface_openhab.rst +++ b/docs/interface_openhab.rst @@ -9,7 +9,7 @@ openHAB Additional configuration ************************************** -For optimal performance it is recommended to use Basic Auth (available from openHAB 3.1 M3 on). +For optimal performance it is recommended to use Basic Auth. It can be enabled through GUI or through textual configuration. Textual configuration @@ -30,6 +30,11 @@ It can be enabled through the gui in ``settings`` -> ``API Security`` -> ``Allow .. image:: /images/openhab_api_config.png +OpenHAB user +====================================== +In case an additional openHAB user or token is created for HABApp it has to have admin rights. + + .. _OPENHAB_ITEM_TYPES: diff --git a/docs/requirements.txt b/docs/requirements.txt index 9e8994d9..9b5b4892 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.2.3 -sphinx_rtd_theme == 2.0.0 -sphinx-exec-code == 0.12 +sphinx == 8.1.3 +sphinx-autodoc-typehints == 2.5.0 +sphinx_rtd_theme == 3.0.2 +sphinx-exec-code == 0.14 autodoc_pydantic == 2.2.0 sphinx-copybutton == 0.5.2 diff --git a/docs/rule.rst b/docs/rule.rst index 51aee0a4..eb2a791c 100644 --- a/docs/rule.rst +++ b/docs/rule.rst @@ -144,6 +144,11 @@ ValueChangeEventFilter .. autoclass:: HABApp.core.events.ValueChangeEventFilter :members: +ValueCommandEventFilter +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. autoclass:: HABApp.core.events.ValueCommandEventFilter + :members: + AndFilterGroup ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. autoclass:: HABApp.core.events.AndFilterGroup @@ -204,10 +209,12 @@ Example MyRule() +.. py:currentmodule:: HABApp.rule.scheduler.job_builder + Scheduler ------------------------------ With the scheduler it is easy to call functions in the future or periodically. -Do not use ``time.sleep`` but rather ``self.run.at``. +Do not use ``time.sleep`` but rather ``self.run.once``. Another very useful function is ``self.run.countdown`` as it can simplify many rules! .. list-table:: @@ -217,56 +224,199 @@ Another very useful function is ``self.run.countdown`` as it can simplify many r * - Function - Description - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.soon` + * - :meth:`~HABAppJobBuilder.soon` - Run the callback as soon as possible (typically in the next second). - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.at` - - Run the callback in x seconds or at a specified time. + * - :meth:`~HABAppJobBuilder.once` + - Run the callback at a specified time. - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.countdown` + * - :meth:`~HABAppJobBuilder.countdown` - Run a function after a time has run down - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.every` - - Run a function periodically + * - :meth:`~HABAppJobBuilder.at` + - Run the callback when a trigger fires. + +Scheduler +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. autoclass:: HABAppJobBuilder + :members: + :inherited-members: + + +Example +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +.. exec_code:: + :hide_output: + + # ------------ hide: start ------------ + from rule_runner import SimpleRuleRunner + runner = SimpleRuleRunner() + runner.set_up() + # ------------ hide: stop ------------- + from HABApp import Rule + from HABApp.rule.scheduler import filter, trigger + + class MyTriggerRule(Rule): + def __init__(self): + super().__init__() - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.every_minute` - - Run a function every minute + # Run the function every day at 12 + self.run.at(self.run.trigger.time('12:00:00'), self.dummy_func) - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.every_hour` - - Run a function every hour + # ------------------------------------------------------------------------------ + # The trigger and filter factories are available as a property on self.run, + # however they can also be used separately when imported + # This is exactly the same as above + self.run.at(trigger.time('12:00:00'), self.dummy_func) - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.on_every_day` - - Run a function at a specific time every day - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.on_workdays` - - Run a function at a specific time on workdays + # ------------------------------------------------------------------------------ + # It's possible to trigger on sun position + # ------------------------------------------------------------------------------ - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.on_weekends` - - Run a function at a specific time on weekends + # Run the function at sunrise + self.run.at(self.run.trigger.sunrise(), self.dummy_func) - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.on_day_of_week` - - Run a function at a specific time on specific days of the week - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.on_sun_dawn` - - Run a function on dawn + # ------------------------------------------------------------------------------ + # Filters can be used to restrict the trigger + # ------------------------------------------------------------------------------ - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.on_sunrise` - - Run a function on sunrise + # Run the function every workday at 12 + self.run.at( + self.run.trigger.time('12:00:00').only_on(self.run.filter.weekdays('Mo-Fr')), + self.dummy_func + ) - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.on_sunset` - - Run a function on sunset - * - :meth:`~HABApp.rule.scheduler.HABAppSchedulerView.on_sun_dusk` - - Run a function on dusk + # ------------------------------------------------------------------------------ + # Triggers offer operations which can shift the trigger time + # ------------------------------------------------------------------------------ + # Run the function one hour after sunrise + self.run.at(self.run.trigger.sunrise().offset(3600), self.dummy_func) + + # Run the function one hour after sunrise, but but earliest at 8 + self.run.at(self.run.trigger.sunrise().offset(3600).earliest('08:00:00'), self.dummy_func) + + + # ------------------------------------------------------------------------------ + # Triggers can be grouped together + # ------------------------------------------------------------------------------ + + # Run the function every workday at 12, but on the weekends at 8 + self.run.at( + self.run.trigger.group( + self.run.trigger.time('12:00:00').only_on(self.run.filter.weekdays('Mo-Fr')), + self.run.trigger.time('08:00:00').only_on(self.run.filter.weekdays('Sa,So')), + ), + self.dummy_func + ) + + + # ------------------------------------------------------------------------------ + # Filters can be grouped together + # ------------------------------------------------------------------------------ + + # Run the function at the first Sunday of every month at 12 + self.run.at( + self.run.trigger.time('12:00:00').only_on( + self.run.filter.all( + self.run.filter.weekdays('So'), + self.run.filter.days('1-7') + ) + ), + self.dummy_func + ) + + + def dummy_func(): + pass + + MyTriggerRule() + + +Reoccuring Jobs +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ -All functions return an instance of ScheduledCallbackBase +Reoccuring jobs are created with :meth:`~HABAppJobBuilder.at`. +The point in time when the job is executed is described by Triggers. +These triggers can be combined and/or restricted with filters. -.. autoclass:: HABApp.rule.scheduler.HABAppSchedulerView +.. autoclass:: eascheduler.builder.triggers.TriggerBuilder :members: :inherited-members: +.. autoclass:: eascheduler.builder.triggers.TriggerObject + :members: + :inherited-members: + +.. autoclass:: eascheduler.builder.filters.FilterBuilder + :members: + :inherited-members: + +Job Control +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + + +.. autoclass:: OneTimeJobControl + :members: + :inherited-members: + +.. autoclass:: CountdownJobControl + :members: + :inherited-members: + +.. autoclass:: DateTimeJobControl + :members: + :inherited-members: + + +Other scheduler related functions +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +.. py:currentmodule:: HABApp.rule.scheduler + +Other scheduler related functions are available under ``HABApp.rule.scheduler``. + +.. list-table:: + :widths: auto + :header-rows: 1 + + * - Function + - Description + + * - :meth:`~get_sun_position` + - Get azimuth and elevation of the sun. + + * - :meth:`~is_holiday` + - Check if a date is a holiday. + + * - :meth:`~add_holiday` + - Add a custom holiday + + * - :meth:`~pop_holiday` + - Remove a holiday + + * - :meth:`~get_holiday_name` + - Get the name of a holiday + + * - :meth:`~get_holidays_by_name` + - Search holidays by name + + +.. autofunction:: get_sun_position + +.. autofunction:: is_holiday + +.. autofunction:: add_holiday + +.. autofunction:: pop_holiday + +.. autofunction:: get_holiday_name +.. autofunction:: get_holidays_by_name Other tools and scripts diff --git a/docs/rule_examples.rst b/docs/rule_examples.rst index 64657c19..27a32f09 100644 --- a/docs/rule_examples.rst +++ b/docs/rule_examples.rst @@ -20,11 +20,17 @@ Get an even when the item is constant for 5 and for 10 seconds. .. exec_code:: # ------------ hide: start ------------ - import time, HABApp + from unittest.mock import Mock + import time, HABApp, eascheduler from rule_runner import SimpleRuleRunner runner = SimpleRuleRunner() runner.set_up() - HABApp.core.Items.add_item(HABApp.core.items.Item('test_watch')) + + # We have no event loop running so this doesn't work. We patch it so this example doesn't raise an error + my_item = HABApp.core.items.Item('test_watch') + my_item.watch_change = lambda x: Mock(spec=HABApp.core.items.base_item_watch.ItemNoUpdateWatch) + + HABApp.core.Items.add_item(my_item) # ------------ hide: stop ------------- import HABApp from HABApp.core.items import Item diff --git a/readme.md b/readme.md index 04a545df..09f9a751 100644 --- a/readme.md +++ b/readme.md @@ -127,6 +127,24 @@ MyOpenhabRule() ``` # Changelog +#### 24.11.0 (2024-11-22) +This is a breaking change! + +Changelog: +- Switched to new scheduler +- Added ``ValueCommandEvent``. The openhab command event inherits from this event. +- Added ``InstantView`` which simplifies dealing with timestamps + +Migration of rules: +- Search for ``self.run.at`` and replace with ``self.run.once`` +- If you use job.offset, job.earliest, job.latest or job.jitter you have to rewrite the condition to the new syntax: + ``self.run.on_sunrise(self.my_function).offset(timedelta(hours=2))`` becomes + ``self.run.at(self.run.trigger.sunrise().offset(timedelta(hours=2)), self.my_function)`` +- All other scheduler functions will emit deprecation warnings with a hint how to rewrite them +- ``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 a1ae591c..81cc2573 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,9 +6,10 @@ # ----------------------------------------------------------------------------- # Packages for source formatting # ----------------------------------------------------------------------------- -pre-commit == 3.5.0 # 3.6.0 requires python >= 3.10 -ruff == 0.5.0 - +pre-commit == 4.0.1 +ruff == 0.8.0 +autotyping == 24.9.0 # ----------------------------------------------------------------------------- # Packages for other developement tasks # ----------------------------------------------------------------------------- +pur == 7.3.2 diff --git a/requirements_setup.txt b/requirements_setup.txt index 21a20135..68e7b688 100644 --- a/requirements_setup.txt +++ b/requirements_setup.txt @@ -1,21 +1,20 @@ -aiohttp == 3.10.0 -pydantic == 2.8.2 -msgspec == 0.18.6 +aiohttp == 3.11.7 +pydantic == 2.10.1 bidict == 0.23.1 -watchdog == 4.0.1 +watchdog == 6.0.0 ujson == 5.10.0 -aiomqtt == 2.2.0 +aiomqtt == 2.3.0 -immutables == 0.20 -eascheduler == 0.1.11 +eascheduler == 0.2.1 + +immutables == 0.21 easyconfig == 0.3.2 -pendulum == 2.1.2 stack_data == 0.6.3 colorama == 0.4.6 fastnumbers == 5.1.0 -voluptuous == 0.14.2 +voluptuous == 0.15.2 typing-extensions == 4.12.2 diff --git a/requirements_tests.txt b/requirements_tests.txt index e4ae1c6e..b0c32dfb 100644 --- a/requirements_tests.txt +++ b/requirements_tests.txt @@ -6,6 +6,6 @@ # ----------------------------------------------------------------------------- # Packages to run source tests # ----------------------------------------------------------------------------- -packaging == 24.1 -pytest == 8.3.2 -pytest-asyncio == 0.23.8 +packaging == 24.2 +pytest == 8.3.3 +pytest-asyncio == 0.24.0 diff --git a/run/conf/rules/async_rule.py b/run/conf/rules/async_rule.py index 59d68b57..ba078b03 100644 --- a/run/conf/rules/async_rule.py +++ b/run/conf/rules/async_rule.py @@ -5,12 +5,12 @@ class AsyncRule(HABApp.Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.run.soon(self.async_func) - async def async_func(self): + async def async_func(self) -> None: await asyncio.sleep(2) async with self.async_http.get('http://httpbin.org/get') as resp: print(resp) diff --git a/run/conf/rules/logging_rule.py b/run/conf/rules/logging_rule.py index 16c3e4f5..c6ae7403 100644 --- a/run/conf/rules/logging_rule.py +++ b/run/conf/rules/logging_rule.py @@ -8,7 +8,7 @@ class MyLoggingRule(HABApp.Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() # different levels are available diff --git a/run/conf/rules/mqtt_rule.py b/run/conf/rules/mqtt_rule.py index 2b89577d..88edbbf7 100644 --- a/run/conf/rules/mqtt_rule.py +++ b/run/conf/rules/mqtt_rule.py @@ -7,7 +7,7 @@ class ExampleMqttTestRule(HABApp.Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.run.every( @@ -20,11 +20,11 @@ def __init__(self): self.listen_event('test/test', self.topic_updated, ValueUpdateEventFilter()) - def publish_rand_value(self): + def publish_rand_value(self) -> None: print('test mqtt_publish') self.my_mqtt_item.publish(str(random.randint(0, 1000))) - def topic_updated(self, event): + def topic_updated(self, event) -> None: assert isinstance(event, ValueUpdateEvent), type(event) print( f'mqtt topic "test/test" updated to {event.value}') diff --git a/run/conf/rules/openhab_rule.py b/run/conf/rules/openhab_rule.py index fe3318ef..23470a12 100644 --- a/run/conf/rules/openhab_rule.py +++ b/run/conf/rules/openhab_rule.py @@ -6,7 +6,7 @@ class MyOpenhabRule(HABApp.Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() # get items @@ -25,11 +25,11 @@ def __init__(self): # Trigger on item commands test_switch.listen_event(self.item_command, ItemCommandEvent) - def item_state_update(self, event): + def item_state_update(self, event) -> None: assert isinstance(event, ValueUpdateEvent) print(f'{event}') - def item_state_change(self, event): + def item_state_change(self, event) -> None: assert isinstance(event, ValueChangeEvent) print(f'{event}') @@ -41,7 +41,7 @@ def item_state_change(self, event): if switch_item.is_on(): switch_item.off() - def item_command(self, event): + def item_command(self, event) -> None: assert isinstance(event, ItemCommandEvent) print( f'{event}') diff --git a/run/conf/rules/openhab_things.py b/run/conf/rules/openhab_things.py index 675a239f..60594f7f 100644 --- a/run/conf/rules/openhab_things.py +++ b/run/conf/rules/openhab_things.py @@ -5,14 +5,14 @@ class CheckAllThings(Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() for thing in self.get_items(Thing): thing.listen_event(self.thing_status_changed, EventFilter(ThingStatusInfoChangedEvent)) print(f'{thing.name}: {thing.status}') - def thing_status_changed(self, event: ThingStatusInfoChangedEvent): + def thing_status_changed(self, event: ThingStatusInfoChangedEvent) -> None: print(f'{event.name} changed from {event.old_status} to {event.status}') diff --git a/run/conf/rules/openhab_to_mqtt_rule.py b/run/conf/rules/openhab_to_mqtt_rule.py index 5ebe21a5..2511b137 100644 --- a/run/conf/rules/openhab_to_mqtt_rule.py +++ b/run/conf/rules/openhab_to_mqtt_rule.py @@ -6,13 +6,13 @@ class ExampleOpenhabToMQTTRule(HABApp.Rule): """This Rule mirrors all updates from OpenHAB to MQTT""" - def __init__(self): + def __init__(self) -> None: super().__init__() for item in self.get_items(OpenhabItem): item.listen_event(self.process_update, ItemStateUpdatedEventFilter()) - def process_update(self, event): + def process_update(self, event) -> None: assert isinstance(event, ItemStateEvent) print(f'/openhab/{event.name} <- {event.value}') diff --git a/run/conf/rules/time_rule.py b/run/conf/rules/time_rule.py index cc003540..6bbd122f 100644 --- a/run/conf/rules/time_rule.py +++ b/run/conf/rules/time_rule.py @@ -5,7 +5,7 @@ class MyRule(Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.run.on_day_of_week(time=time(14, 34, 20), weekdays=['Mo'], callback=self.run_mondays) @@ -15,16 +15,16 @@ def __init__(self): self.run.on_workdays(time(15, 00), self.run_workdays) self.run.on_weekends(time(15, 00), self.run_weekends) - def run_every_3s(self, arg, asdf = None): + def run_every_3s(self, arg, asdf = None) -> None: print(f'run_ever_3s: {datetime.now().replace(microsecond=0)} : {arg}, {asdf}') - def run_mondays(self): + def run_mondays(self) -> None: print('Today is monday!') - def run_workdays(self): + def run_workdays(self) -> None: print('Today is a workday!') - def run_weekends(self): + def run_weekends(self) -> None: print('Finally weekend!') diff --git a/run/conf_testing/config.yml b/run/conf_testing/config.yml index cf645909..f888a4fb 100644 --- a/run/conf_testing/config.yml +++ b/run/conf_testing/config.yml @@ -44,3 +44,16 @@ openhab: general: listen_only: false # If True HABApp will not change anything on the openHAB instance. wait_for_openhab: true # If True HABApp will wait for items from the openHAB instance before loading any rules on startup + + +habapp: + debug: + watch event loop: + enabled: True + reset every: 10 + timeout: 25 + + periodic traceback: + enabled: True + delay: 20 + interval: 600 diff --git a/run/conf_testing/lib/HABAppTests/compare_values.py b/run/conf_testing/lib/HABAppTests/compare_values.py index 7bb3a1f9..dc14543d 100644 --- a/run/conf_testing/lib/HABAppTests/compare_values.py +++ b/run/conf_testing/lib/HABAppTests/compare_values.py @@ -1,7 +1,7 @@ from .utils import get_bytes_text -def get_equal_text(value1, value2): +def get_equal_text(value1, value2) -> str: return f'{get_value_text(value1)} {"==" if value1 == value2 else "!="} {get_value_text(value2)}' diff --git a/run/conf_testing/lib/HABAppTests/errors.py b/run/conf_testing/lib/HABAppTests/errors.py index c90bafcd..9528c5fa 100644 --- a/run/conf_testing/lib/HABAppTests/errors.py +++ b/run/conf_testing/lib/HABAppTests/errors.py @@ -1,8 +1,8 @@ class TestCaseFailed(Exception): - def __init__(self, msg: str): + def __init__(self, msg: str) -> None: self.msg = msg class TestCaseWarning(Exception): - def __init__(self, msg: str): + 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 a5f2568e..ee527912 100644 --- a/run/conf_testing/lib/HABAppTests/event_waiter.py +++ b/run/conf_testing/lib/HABAppTests/event_waiter.py @@ -1,10 +1,10 @@ import logging import time -from typing import Any, Dict, TypeVar, Union +from types import TracebackType +from typing import Any, TypeVar from HABApp.core.events.filter import EventFilter from HABApp.core.internals import ( - HINT_EVENT_FILTER_OBJ, EventBusListener, EventFilterBase, get_current_context, @@ -22,8 +22,8 @@ class EventWaiter: - def __init__(self, name: Union[BaseValueItem, str], - event_filter: HINT_EVENT_FILTER_OBJ, timeout=1): + def __init__(self, name: BaseValueItem | str, + event_filter: EventFilterBase, timeout: float = 1) -> None: if isinstance(name, BaseValueItem): name = name.name assert isinstance(name, str) @@ -41,12 +41,12 @@ def __init__(self, name: Union[BaseValueItem, str], self._received_events = [] - def __process_event(self, event): + def __process_event(self, event) -> None: if isinstance(self.event_filter, EventFilter): assert isinstance(event, self.event_filter.event_class) self._received_events.append(event) - def clear(self): + def clear(self) -> None: self._received_events.clear() def wait_for_event(self, **kwargs) -> EVENT_TYPE: @@ -79,11 +79,11 @@ def __enter__(self) -> 'EventWaiter': get_current_context().add_event_listener(self.event_listener) return self - def __exit__(self, exc_type, exc_val, exc_tb): + 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, kwargs: dict[str, Any]): 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 7228bfb1..4905c6ee 100644 --- a/run/conf_testing/lib/HABAppTests/item_waiter.py +++ b/run/conf_testing/lib/HABAppTests/item_waiter.py @@ -1,5 +1,6 @@ import logging import time +from types import TracebackType from HABApp.core.items import BaseValueItem from HABAppTests.compare_values import get_equal_text @@ -10,13 +11,13 @@ class ItemWaiter: - def __init__(self, item, timeout=1): + def __init__(self, item, timeout=1) -> None: self.item = item assert isinstance(item, BaseValueItem), f'{item} is not an Item' self.timeout = timeout - def wait_for_attribs(self, **kwargs): + def wait_for_attribs(self, **kwargs) -> bool: start = time.time() end = start + self.timeout @@ -45,5 +46,5 @@ def wait_for_state(self, state=None): def __enter__(self) -> 'ItemWaiter': return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> bool: pass diff --git a/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py index 040a3120..f8de4515 100644 --- a/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py +++ b/run/conf_testing/lib/HABAppTests/openhab_tmp_item.py @@ -1,6 +1,6 @@ import time from functools import wraps -from typing import List, Optional +from types import TracebackType import HABApp from HABApp.openhab.definitions.topics import TOPIC_ITEMS @@ -10,7 +10,7 @@ class OpenhabTmpItem: @staticmethod - def use(item_type: str, name: Optional[str] = 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): @@ -25,7 +25,7 @@ def new_func(*args, **kwargs): return decorator @staticmethod - def create(item_type: str, name: Optional[str] = None, arg_name: Optional[str] = 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): @@ -37,30 +37,31 @@ def new_func(*args, **kwargs): return new_func return decorator - def __init__(self, item_type: str, item_name: Optional[str] = None): + 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 def __enter__(self) -> HABApp.openhab.items.OpenhabItem: return self.create_item() - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> bool: self.remove() + return False - def remove(self): + def remove(self) -> None: HABApp.openhab.interface_sync.remove_item(self.name) - def _create(self, label='', category='', tags: List[str] = [], groups: List[str] = [], + def _create(self, label='', category='', tags: list[str] = [], groups: list[str] = [], group_type: str = '', group_function: str = '', - group_function_params: List[str] = []): + group_function_params: list[str] = []) -> None: 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) - def create_item(self, label='', category='', tags: List[str] = [], groups: List[str] = [], + 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: + group_function_params: list[str] = []) -> HABApp.openhab.items.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) @@ -75,8 +76,8 @@ def create_item(self, label='', category='', tags: List[str] = [], groups: List[ 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] = []): + def modify(self, label='', category='', tags: list[str] = [], groups: list[str] = [], + group_type: str = '', group_function: str = '', group_function_params: list[str] = []) -> None: with EventWaiter(TOPIC_ITEMS, HABApp.core.events.EventFilter(HABApp.openhab.events.ItemUpdatedEvent)) as w: diff --git a/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py b/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py index 46b74004..06077def 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/_rest_patcher.py @@ -1,8 +1,9 @@ import json import logging import pprint +from types import TracebackType -from pytest import MonkeyPatch +from pytest import MonkeyPatch # noqa: PT013 import HABApp.openhab.connection.handler import HABApp.openhab.connection.handler.func_async @@ -10,7 +11,7 @@ from HABApp.config import CONFIG -def shorten_url(url: str): +def shorten_url(url: str) -> str: url = str(url) cfg = CONFIG.openhab.connection.url if url.startswith(cfg): @@ -19,14 +20,14 @@ def shorten_url(url: str): class RestPatcher: - def __init__(self, name: str): + 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): + def log(self, msg: str) -> None: # Log name when we log the first message if not self.logged_name: self.logged_name = True @@ -87,7 +88,7 @@ def new_call(_dict): return to_wrap(_dict) return new_call - def __enter__(self): + def __enter__(self) -> None: m = self.monkeypatch # event handler @@ -104,6 +105,6 @@ def __enter__(self): m.setattr(module, 'put', self.wrap(getattr(module, 'put'))) m.setattr(module, 'post', self.wrap(getattr(module, 'post'))) - def __exit__(self, exc_type, exc_val, exc_tb): + 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 index 237b598a..85800fae 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/_rule_ids.py @@ -14,7 +14,7 @@ class RuleID: - def __init__(self, id: int): + def __init__(self, id: int) -> None: self.__id = id def is_newest(self) -> bool: @@ -23,7 +23,7 @@ def is_newest(self) -> bool: return False return True - def remove(self): + def remove(self) -> None: pop_test_rule(self.__id) @@ -37,7 +37,7 @@ def get_next_id(rule) -> RuleID: return obj -def pop_test_rule(id: int): +def pop_test_rule(id: int) -> None: with LOCK: rule = TESTS_RULES.pop(id) rule._rule_status = TestRuleStatus.FINISHED 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 index c3c4526c..02035508 100644 --- 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 @@ -1,12 +1,12 @@ import time -from typing import Callable +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={}): + def __init__(self, name: str, func: Callable, args=[], kwargs={}) -> None: self.name = name self.func = func self.args = args diff --git a/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py b/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py index 8e5f3eee..4dbb1b3a 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_case/test_result.py @@ -1,6 +1,5 @@ import logging from enum import IntEnum, auto -from typing import List, Optional import HABApp from HABAppTests.errors import TestCaseFailed, TestCaseWarning @@ -29,18 +28,18 @@ class TestResultStatus(IntEnum): class TestResult: - def __init__(self, cls_name: str, test_name: str, test_nr: str = ''): + 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.state = TestResultStatus.NOT_SET - self.msgs: List[str] = [] + self.msgs: list[str] = [] def is_set(self): return self.state != TestResultStatus.NOT_SET - def set_state(self, new_state: TestResultStatus): + def set_state(self, new_state: TestResultStatus) -> None: if self.state <= new_state: self.state = new_state @@ -60,11 +59,11 @@ def exception(self, e: Exception): for line in HABApp.core.wrapper.format_exception(e): log.error(line) - def add_msg(self, msg: str): + def add_msg(self, msg: str) -> None: for line in msg.splitlines(): self.msgs.append(line) - def log(self, name: Optional[str] = None): + def log(self, name: str | None = None): if name is None: name = f'{self.cls_name}.{self.test_name}' nr = f' {self.test_nr} ' if self.test_nr else ' ' 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 171cd67d..9250ab0f 100644 --- a/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py +++ b/run/conf_testing/lib/HABAppTests/test_rule/test_rule.py @@ -1,6 +1,6 @@ import logging +from collections.abc import Callable from pathlib import Path -from typing import Callable, Dict, List import HABApp from HABAppTests.test_rule.test_case import TestCase, TestResult, TestResultStatus @@ -13,7 +13,7 @@ class TestConfig: - def __init__(self): + def __init__(self) -> None: self.skip_on_failure = False self.warning_is_error = False @@ -21,11 +21,11 @@ def __init__(self): class TestBaseRule(HABApp.Rule): """This rule is testing the OpenHAB data types by posting values and checking the events""" - def __init__(self): + def __init__(self) -> None: super().__init__() self._rule_status = TestRuleStatus.CREATED self._rule_id = get_next_id(self) - self._tests: Dict[str, TestCase] = {} + self._tests: dict[str, TestCase] = {} self.__warnings = [] self.__errors = [] @@ -39,18 +39,18 @@ def __init__(self): # we have to chain the rules later, because we register the rules only once we loaded successfully. self.run.at(2, self.__execute_run) - def on_rule_unload(self): + def on_rule_unload(self) -> None: self._rule_id.remove() # ------------------------------------------------------------------------------------------------------------------ # Overrides and test - def set_up(self): + def set_up(self) -> None: pass - def tear_down(self): + def tear_down(self) -> None: pass - def add_test(self, name, func: Callable, *args, **kwargs): + 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 @@ -83,7 +83,7 @@ def __execute_run(self): 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): + def plog(msg: str) -> None: print(msg) log.info(msg) @@ -102,19 +102,19 @@ def plog(msg: str): # ------------------------------------------------------------------------------------------------------------------ # Event from the worker - def __event_warning(self, event): + def __event_warning(self, event) -> None: self.__warnings.append(event) - def __event_error(self, event): + def __event_error(self, event) -> None: self.__errors.append(event) - def _worker_events_sub(self): + 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): + def _worker_events_cancel(self) -> None: if self.__sub_warning is not None: self.__sub_warning.cancel() if self.__sub_errors is not None: @@ -122,7 +122,7 @@ def _worker_events_cancel(self): # ------------------------------------------------------------------------------------------------------------------ # Test execution - def __exec_tc(self, res: TestResult, tc: TestCase): + def __exec_tc(self, res: TestResult, tc: TestCase) -> None: self.__warnings.clear() self.__errors.clear() @@ -144,7 +144,7 @@ def __exec_tc(self, res: TestResult, tc: TestCase): self.__worst_result = max(self.__worst_result, res.state) - def _run_tests(self) -> List[TestResult]: + def _run_tests(self) -> list[TestResult]: self._rule_status = TestRuleStatus.RUNNING self._worker_events_sub() @@ -170,7 +170,7 @@ def _run_tests(self) -> List[TestResult]: self._rule_status = TestRuleStatus.FINISHED return results - def __run_tests(self) -> List[TestResult]: + def __run_tests(self) -> list[TestResult]: count = len(self._tests) width = 1 while count >= 10 ** width: diff --git a/run/conf_testing/lib/HABAppTests/utils.py b/run/conf_testing/lib/HABAppTests/utils.py index 04b81de9..f810366e 100644 --- a/run/conf_testing/lib/HABAppTests/utils.py +++ b/run/conf_testing/lib/HABAppTests/utils.py @@ -50,6 +50,6 @@ def find_astro_sun_thing() -> str: def get_bytes_text(value): - if isinstance(value, bytes) and len(value) > 100 * 1024: - return b2a_hex(value[0:40]).decode() + ' ... ' + b2a_hex(value[-40:]).decode() + if isinstance(value, bytes) and len(value) > 300: + return b2a_hex(value[:40]).decode() + ' ... ' + b2a_hex(value[-40:]).decode() return value diff --git a/run/conf_testing/rules/habapp/test_event_listener.py b/run/conf_testing/rules/habapp/test_event_listener.py index 8d357b93..77e3bf3a 100644 --- a/run/conf_testing/rules/habapp/test_event_listener.py +++ b/run/conf_testing/rules/habapp/test_event_listener.py @@ -14,12 +14,12 @@ class TestNoWarningOnRuleUnload(TestBaseRule): """This rule tests that multiple listen/cancel commands don't create warnings on unload""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('CheckWarning', self.test_unload) - def test_unload(self): + def test_unload(self) -> None: item = Item.get_create_item(get_random_name('HABApp')) grp = EventListenerGroup().add_listener(item, self.cb, ValueChangeEventFilter()) @@ -40,7 +40,7 @@ def test_unload(self): self.on_rule_unload = lambda: None self._habapp_ctx = HABAppRuleContext(self) - def cb(self, event): + def cb(self, event) -> None: pass diff --git a/run/conf_testing/rules/habapp/test_group_listener.py b/run/conf_testing/rules/habapp/test_group_listener.py index ffbf4486..ba520f68 100644 --- a/run/conf_testing/rules/habapp/test_group_listener.py +++ b/run/conf_testing/rules/habapp/test_group_listener.py @@ -15,7 +15,7 @@ class TestListenerGroup(TestBaseRule): """This rule is testing the Parameter implementation""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.my_item1 = Item.get_create_item('EventGroupItem_1') @@ -29,7 +29,7 @@ def __init__(self): self.calls = [] - def __callback(self, event): + def __callback(self, event) -> None: self.calls.append(event) def wait_for_cb(self, expected_len: int, min_time=None): @@ -45,7 +45,7 @@ def wait_for_cb(self, expected_len: int, min_time=None): raise TestCaseFailed('Timeout while waiting for calls!') time.sleep(0.01) - def test_basic(self): + def test_basic(self) -> None: self.calls.clear() self.grp.listen() @@ -66,7 +66,7 @@ def test_basic(self): assert self.calls[0].value == 1 assert self.calls[0].name == self.my_item2.name - def test_deactivate(self): + def test_deactivate(self) -> None: self.calls.clear() self.grp.listen() diff --git a/run/conf_testing/rules/habapp/test_habapp.py b/run/conf_testing/rules/habapp/test_habapp.py index 98ff6bab..e5c32352 100644 --- a/run/conf_testing/rules/habapp/test_habapp.py +++ b/run/conf_testing/rules/habapp/test_habapp.py @@ -15,12 +15,12 @@ class TestItemEvents(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('Item const', self.item_events, changes=False, secs=2, values=['MyVal', 'MyVal', 'MyVal']) self.add_test('Item change', self.item_events, changes=True, secs=2, values=['MyVal1', 'MyVal2', 'MyVal3']) - def check_event(self, event: ItemNoUpdateEvent): + def check_event(self, event: ItemNoUpdateEvent) -> None: assert event.name == self.watch_item.name, f'Wrong name: {event.name} != {self.watch_item.name}' assert event.seconds == self.secs, f'Wrong seconds: {event.seconds} != {self.secs}' dur = time.time() - self.ts_set - self.secs @@ -50,7 +50,7 @@ def item_events(self, changes=False, secs=5, values=[]): watcher.cancel() return None - def _run(self, values, filter): + def _run(self, values, filter) -> None: self.ts_set = 0 for step, value in enumerate(values): if step: @@ -67,13 +67,13 @@ def _run(self, values, filter): class TestItemEventRestore(TestBaseRule): """Test that item listeners are properly restored when an item is removed and added again""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('const change', self.test_restore, change=True) self.add_test('const update', self.test_restore, change=False) - def test_restore(self, change=False): + def test_restore(self, change=False) -> None: item = HABApp.core.items.Item.get_create_item(get_random_name('HABApp')) timeout = 0.2 (item.watch_change if change else item.watch_update)(timeout) @@ -98,22 +98,22 @@ def test_restore(self, change=False): class TestItemListener(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('Item.listen_event', self.trigger_event) - def check_event(self, event: ValueUpdateEvent): + def check_event(self, event: ValueUpdateEvent) -> None: assert event.name == self.watch_item.name, f'Wrong name: {event.name} != {self.watch_item.name}' assert event.value == 123, f'Wrong value: {event.value} != 123' - def set_up(self): + def set_up(self) -> None: self.watch_item = Item.get_create_item(get_random_name('HABApp')) self.listener = self.watch_item.listen_event(self.check_event, ValueUpdateEventFilter()) - def tear_down(self): + def tear_down(self) -> None: self.listener.cancel() - def trigger_event(self): + def trigger_event(self) -> None: self.run.at( 1, self.post_event, self.watch_item.name, ValueUpdateEvent(self.watch_item.name, 123) ) diff --git a/run/conf_testing/rules/habapp/test_parameter_files.py b/run/conf_testing/rules/habapp/test_parameter_files.py index 7beb0052..59d0a72e 100644 --- a/run/conf_testing/rules/habapp/test_parameter_files.py +++ b/run/conf_testing/rules/habapp/test_parameter_files.py @@ -19,12 +19,12 @@ class TestParamFile(TestBaseRule): """This rule is testing the Parameter implementation""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('ParamFile', self.test_param_file) - def test_param_file(self): + def test_param_file(self) -> None: p = HABApp.Parameter('param_file', 'key') assert p < 11 assert p.value == 10 diff --git a/run/conf_testing/rules/habapp/test_rule_context.py b/run/conf_testing/rules/habapp/test_rule_context.py index e552f825..5856dc78 100644 --- a/run/conf_testing/rules/habapp/test_rule_context.py +++ b/run/conf_testing/rules/habapp/test_rule_context.py @@ -8,32 +8,45 @@ class OtherRule(Rule): - def check_context(self): + def check_context(self) -> None: + assert get_current_context() is self._habapp_ctx + + def async_check_context(self) -> None: assert get_current_context() is self._habapp_ctx other_rule = OtherRule() -def func_no_context(target_context): +def func_no_context(target_context) -> None: + assert get_current_context() is target_context + + +async def async_func_no_context(target_context) -> None: assert get_current_context() is target_context class TestContext(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('TestGetContext', self.test_get_context) self.add_test('TestSchedulerContext', self.test_scheduler_context) + self.add_test('TestSchedulerAsyncContext', self.test_scheduler_async_context) - def test_get_context(self): + def test_get_context(self) -> None: assert get_current_context() is self._habapp_ctx other_rule.check_context() - def test_scheduler_context(self): + def test_scheduler_context(self) -> None: self.run.soon(func_no_context, self._habapp_ctx) self.run.soon(other_rule.check_context) sleep(0.1) + def test_scheduler_async_context(self) -> None: + self.run.soon(async_func_no_context, self._habapp_ctx) + self.run.soon(other_rule.async_check_context) + sleep(0.1) + TestContext() diff --git a/run/conf_testing/rules/habapp/test_scheduler.py b/run/conf_testing/rules/habapp/test_scheduler.py index 922a5038..9f830ffe 100644 --- a/run/conf_testing/rules/habapp/test_scheduler.py +++ b/run/conf_testing/rules/habapp/test_scheduler.py @@ -1,62 +1,77 @@ -import logging -import time +from time import monotonic, sleep from HABAppTests import TestBaseRule, get_random_name +from HABApp import Rule from HABApp.core.events import ValueUpdateEventFilter from HABApp.core.items import Item -log = logging.getLogger('HABApp.TestParameterFiles') +class TestSchedulerCallLive(Rule): + """This rule is testing the Scheduler implementation""" + + def __init__(self) -> None: + super().__init__() + + self.sunrise = self.run.at(self.run.trigger.sunrise(), print, 'sunrise') + self.sunset = self.run.at(self.run.trigger.sunset(), print, 'sunset') + + self.run.soon(self.show_times) + + def show_times(self) -> None: + print(f'Sunrise: {self.sunrise.get_next_run()}') + print(f'Sunset : {self.sunset.get_next_run()}') + + +TestSchedulerCallLive() class TestScheduler(TestBaseRule): """This rule is testing the Scheduler implementation""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('Test scheduler every', self.test_scheduler_every) - f = self.run.on_sunrise(print, 'sunrise') - print(f'Sunrise: {f.get_next_run()}') - - f = self.run.on_sunset(print, 'sunset') - print(f'Sunset : {f.get_next_run()}') + self.run.at(self.run.trigger.sunrise(), print, 'sunrise') + self.run.at(self.run.trigger.sunset(), print, 'sunset') self.item = Item.get_create_item(get_random_name('HABApp')) self.item.listen_event(lambda x: self.item_states.append(x), ValueUpdateEventFilter()) self.item_states = [] - def test_scheduler_every(self): + def test_scheduler_every(self) -> None: - executions = 5 + self.item_states.clear() calls = [] - def called(): - calls.append(time.time()) + def called() -> None: + calls.append(monotonic()) job = self.run.every(None, 0.5, called) job.to_item(self.item) + executions = 10 try: - started = time.time() - while time.time() - started < 7: - time.sleep(0.1) + started = monotonic() + while monotonic() - started < executions * 0.6 + 1: + sleep(0.1) if len(calls) >= executions: break - - assert len(calls) >= executions, calls - - for i in range(len(calls) - 1): - diff = calls[i + 1] - calls[i] - assert 0.47 <= diff <= 0.53, diff - finally: job.cancel() - assert len(self.item_states) == 6 + assert len(calls) == executions, calls + + for i in range(len(calls) - 1): + diff = calls[i + 1] - calls[i] + assert 0.46 <= diff <= 0.54, diff + + sleep(0.1) + assert len(self.item_states) == executions + 1 # First event before the first call, then None as the last event + assert self.item_states[-1].value is None TestScheduler() diff --git a/run/conf_testing/rules/habapp/test_util_fade.py b/run/conf_testing/rules/habapp/test_util_fade.py index 82da0833..dea96e41 100644 --- a/run/conf_testing/rules/habapp/test_util_fade.py +++ b/run/conf_testing/rules/habapp/test_util_fade.py @@ -9,19 +9,19 @@ class TestFadeRun(TestBaseRule): """This rule is testing the Parameter implementation""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('TestFade', self.test_fade) - def test_fade(self): + def test_fade(self) -> None: item = Item.get_create_item('TestFadeItem') item.set_value(None) vals = {} now = time.time() - def add_value(v): + def add_value(v) -> None: vals[time.time() - now] = v item.set_value(v) diff --git a/run/conf_testing/rules/habapp/test_utils.py b/run/conf_testing/rules/habapp/test_utils.py index 43ca0011..c9d92d6e 100644 --- a/run/conf_testing/rules/habapp/test_utils.py +++ b/run/conf_testing/rules/habapp/test_utils.py @@ -13,13 +13,13 @@ class TestSwitchMode(TestBaseRule): """This rule is testing the Parameter implementation""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('SwitchItemValueMode', self.test_sw_mode) self.add_test('SwitchItemValueMode inverted', self.test_sw_mode_inverted) - def test_sw_mode(self): + def test_sw_mode(self) -> None: mm = MultiModeItem.get_create_item(get_random_name('HABApp')) with OpenhabTmpItem('Switch') as switch, ItemWaiter(OpenhabItem.get_item(switch.name)) as waiter: @@ -44,7 +44,7 @@ def test_sw_mode(self): HABApp.core.Items.pop_item(mm.name) - def test_sw_mode_inverted(self): + def test_sw_mode_inverted(self) -> None: mm = MultiModeItem.get_create_item(get_random_name('HABApp')) with OpenhabTmpItem('Switch') as switch, ItemWaiter(OpenhabItem.get_item(switch.name)) as waiter: diff --git a/run/conf_testing/rules/openhab/test_event_types.py b/run/conf_testing/rules/openhab/test_event_types.py index f579ff6b..e8138fd6 100644 --- a/run/conf_testing/rules/openhab/test_event_types.py +++ b/run/conf_testing/rules/openhab/test_event_types.py @@ -14,7 +14,7 @@ class TestOpenhabEventTypes(TestBaseRule): """This rule is testing the OpenHAB data types by posting values and checking the events""" - def __init__(self): + def __init__(self) -> None: super().__init__() # test the states @@ -29,7 +29,7 @@ def __init__(self): for name, unit in dimensions.items(): self.add_test(f'Quantity {name} events', self.test_quantity_type_events, name, unit) - def test_events(self, item_type, test_values): + def test_events(self, item_type, test_values) -> None: item_name = f'{item_type}_value_test' with OpenhabTmpItem(item_type, item_name), EventWaiter(item_name, ValueUpdateEventFilter()) as waiter: @@ -43,7 +43,7 @@ def test_events(self, item_type, test_values): self.openhab.send_command(item_name, value) waiter.wait_for_event(value=value) - def test_quantity_type_events(self, dimension, unit): + def test_quantity_type_events(self, dimension, unit) -> None: item_name = f'{dimension}_event_test' with OpenhabTmpItem(f'Number:{dimension}', item_name) as item, \ EventWaiter(item_name, ValueUpdateEventFilter()) as event_waiter, \ diff --git a/run/conf_testing/rules/openhab/test_groups.py b/run/conf_testing/rules/openhab/test_groups.py index 39f15883..2860c06d 100644 --- a/run/conf_testing/rules/openhab/test_groups.py +++ b/run/conf_testing/rules/openhab/test_groups.py @@ -7,7 +7,7 @@ class TestOpenhabGroupFunction(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.group = OpenhabTmpItem('Group') @@ -17,17 +17,17 @@ def __init__(self): self.add_test('Group function', self.test_group_update) self.add_test('Group member change', self.add_item_to_grp) - def set_up(self): + def set_up(self) -> None: self.item1.create_item(groups=[self.group.name]) self.item2.create_item(groups=[self.group.name]) self.group.create_item(group_type='Switch', group_function='OR', group_function_params=['ON', 'OFF']) - def tear_down(self): + def tear_down(self) -> None: self.item1.remove() self.item2.remove() self.group.remove() - def test_group_update(self): + def test_group_update(self) -> None: item1 = SwitchItem.get_item(self.item1.name) item2 = SwitchItem.get_item(self.item2.name) group = GroupItem.get_item(self.group.name) diff --git a/run/conf_testing/rules/openhab/test_habapp_internals.py b/run/conf_testing/rules/openhab/test_habapp_internals.py index d60cece3..6492829e 100644 --- a/run/conf_testing/rules/openhab/test_habapp_internals.py +++ b/run/conf_testing/rules/openhab/test_habapp_internals.py @@ -10,11 +10,11 @@ class OpenhabMetaData(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('async', self.create_meta) - def create_meta(self): + def create_meta(self) -> None: with OpenhabTmpItem('String') as tmpitem: d = run_coro(async_get_item_with_habapp_meta(tmpitem.name)) assert d.metadata['HABApp'] is None diff --git a/run/conf_testing/rules/openhab/test_interface.py b/run/conf_testing/rules/openhab/test_interface.py index d1da0bc0..b09bddc5 100644 --- a/run/conf_testing/rules/openhab/test_interface.py +++ b/run/conf_testing/rules/openhab/test_interface.py @@ -25,7 +25,7 @@ class TestOpenhabInterface(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('Interface item exists', self.test_item_exists) @@ -44,11 +44,11 @@ def __init__(self): self.add_test('Interface Metadata', self.test_metadata) self.add_test('Test async order', self.test_async_oder) - def test_item_exists(self): + def test_item_exists(self) -> None: assert not self.openhab.item_exists('item_which_does_not_exist') assert self.openhab.item_exists('TestString') - def test_item_create_delete(self): + def test_item_create_delete(self) -> None: test_defs = [] for type in get_openhab_test_types(): test_defs.append((type, get_random_name(type))) @@ -63,7 +63,7 @@ def test_item_create_delete(self): self.openhab.remove_item(item_name) assert not self.openhab.item_exists(item_name) - def test_item_change_type(self): + def test_item_change_type(self) -> None: test_item = get_random_name('String') assert not self.openhab.item_exists(test_item) @@ -85,7 +85,7 @@ def test_item_change_type(self): self.openhab.remove_item(test_item) - def test_item_create_delete_group(self): + def test_item_create_delete_group(self) -> None: test_item = get_random_name('String') test_group = get_random_name('Group') assert not self.openhab.item_exists(test_item) @@ -103,7 +103,7 @@ def test_item_create_delete_group(self): self.openhab.remove_item(test_group) self.openhab.remove_item(test_item) - def test_post_update(self, oh_type, values): + def test_post_update(self, oh_type, values) -> None: if isinstance(values, str): values = [values] @@ -118,28 +118,28 @@ def test_post_update(self, oh_type, values): waiter.wait_for_state(value) @OpenhabTmpItem.use('String') - def test_umlaute(self, item: OpenhabTmpItem): + def test_umlaute(self, item: OpenhabTmpItem) -> None: LABEL = 'äöß' self.openhab.create_item('String', item.name, label=LABEL) ret = self.openhab.get_item(item.name) assert ret.label == LABEL, f'"{LABEL}" != "{ret.label}"' - def test_openhab_item_not_found(self): + def test_openhab_item_not_found(self) -> None: test_item = get_random_name('String') assert self.openhab.get_item(test_item) is None - def test_item_definition(self): + def test_item_definition(self) -> None: self.openhab.get_item('TestGroupAVG') self.openhab.get_item('TestNumber') self.openhab.get_item('TestString') - def test_metadata(self): + def test_metadata(self) -> None: with OpenhabTmpItem('String') as item: self.openhab.set_metadata(item, 'MyNameSpace', 'MyValue', {'key': 'value'}) self.openhab.remove_metadata(item, 'MyNameSpace') - def test_async_oder(self): + def test_async_oder(self) -> None: with OpenhabTmpItem('String', 'AsyncOrderTest') as item, ItemWaiter(item) as waiter: for _ in range(10): for i in range(5): diff --git a/run/conf_testing/rules/openhab/test_interface_links.py b/run/conf_testing/rules/openhab/test_interface_links.py index e5329a9a..90a86215 100644 --- a/run/conf_testing/rules/openhab/test_interface_links.py +++ b/run/conf_testing/rules/openhab/test_interface_links.py @@ -5,7 +5,7 @@ class TestOpenhabInterfaceLinks(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.skip_on_failure = True @@ -20,7 +20,7 @@ def __init__(self): self.add_test('link removal', self.test_remove_link) self.add_test('link update', self.test_update_link) - def __create_test_item(self): + def __create_test_item(self) -> None: self.openhab.create_item('Number', self.item_name) def set_up(self): @@ -32,7 +32,7 @@ def set_up(self): if not self.openhab.item_exists(self.item_name): raise Exception('item could not be created') - def tear_down(self): + def tear_down(self) -> None: try: self.oh.get_link(self.item_name, self.channel_uid) self.oh.remove_link(self.item_name, self.channel_uid) @@ -48,7 +48,7 @@ def __find_astro_sun_thing(self) -> str: return found_uid - def test_update_link(self): + def test_update_link(self) -> None: assert self.oh.create_link(self.item_name, self.channel_uid, {'profile': 'system:default'}) assert self.oh.get_link(self.item_name, self.channel_uid) @@ -58,7 +58,7 @@ def test_update_link(self): channel_link = self.oh.get_link(self.item_name, self.channel_uid) assert channel_link.configuration == new_cfg - def test_get_link(self): + def test_get_link(self) -> None: target = {'profile': 'system:default'} assert self.oh.create_link(self.item_name, self.channel_uid, target) link = self.oh.get_link(self.item_name, self.channel_uid) @@ -67,7 +67,7 @@ def test_get_link(self): assert link.channel == self.channel_uid assert link.configuration == target - def test_remove_link(self): + def test_remove_link(self) -> None: assert self.oh.create_link(self.item_name, self.channel_uid, {'profile': 'system:default'}) self.oh.remove_link(self.item_name, self.channel_uid) try: @@ -76,7 +76,7 @@ def test_remove_link(self): except LinkNotFoundError: pass - def test_link_existence(self): + def test_link_existence(self) -> None: assert self.oh.create_link(self.item_name, self.channel_uid, {'profile': 'system:default'}) assert self.oh.get_link(self.item_name, self.channel_uid) @@ -87,7 +87,7 @@ def test_link_existence(self): except LinkNotFoundError: pass - def test_create_link(self): + def test_create_link(self) -> None: assert self.oh.create_link(self.item_name, self.channel_uid, {'profile': 'system:default'}) diff --git a/run/conf_testing/rules/openhab/test_item_change.py b/run/conf_testing/rules/openhab/test_item_change.py index 58ee1640..1cec84d1 100644 --- a/run/conf_testing/rules/openhab/test_item_change.py +++ b/run/conf_testing/rules/openhab/test_item_change.py @@ -9,11 +9,11 @@ class ChangeItemType(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('change_item', self.change_item) - def change_item(self): + def change_item(self) -> None: with OpenhabTmpItem('Number') as tmpitem: NumberItem.get_item(tmpitem.name) diff --git a/run/conf_testing/rules/openhab/test_item_funcs.py b/run/conf_testing/rules/openhab/test_item_funcs.py index 63140949..571157ca 100644 --- a/run/conf_testing/rules/openhab/test_item_funcs.py +++ b/run/conf_testing/rules/openhab/test_item_funcs.py @@ -1,6 +1,5 @@ import dataclasses import logging -import typing from HABAppTests import ItemWaiter, OpenhabTmpItem, TestBaseRule, get_openhab_test_states, get_openhab_test_types @@ -23,14 +22,14 @@ @dataclasses.dataclass(frozen=True) class TestParam: func_name: str - result: typing.Union[str, float, int, tuple] - func_params: typing.Union[str, float, int, tuple] = None + result: str | float | int | tuple + func_params: str | float | int | tuple = None class TestOpenhabItemFuncs(TestBaseRule): """This rule is testing the OpenHAB item types by calling functions and checking values""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_func_test(ContactItem, {TestParam('open', 'OPEN'), TestParam('closed', 'CLOSED')}) @@ -74,11 +73,11 @@ def __init__(self): } ) - def add_func_test(self, cls, params: set): + def add_func_test(self, cls, params: set) -> None: # -> SwitchItem self.add_test(cls.__name__, self.test_func, cls, params) - def test_func(self, item_type, test_params): + def test_func(self, item_type, test_params) -> None: # create a nice name for the tmp item item_type = str(item_type).split('.')[-1][:-6] @@ -115,31 +114,34 @@ def test_func(self, item_type, test_params): class TestOpenhabItemConvenience(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() - for name in ('oh_post_update', 'oh_send_command'): + # oh_send_command and command_value is the same for openhab items + for name in ('oh_post_update', 'oh_send_command', 'command_value'): for k in get_openhab_test_types(): - if name == 'oh_send_command' and k == 'Contact': + if name in ('oh_send_command', 'command_value') and k == 'Contact': continue self.add_test(f'{k}.{name}', self.test_func, k, name, get_openhab_test_states(k)) self.add_test('post_value_if', self.test_post_update_if) - def test_func(self, item_type, func_name, test_vals): + def test_func(self, item_type, func_name, test_vals) -> None: with OpenhabTmpItem(item_type) as tmpitem, ItemWaiter(OpenhabItem.get_item(tmpitem.name)) as waiter: for val in test_vals: getattr(tmpitem, func_name)(val) waiter.wait_for_state(val) - for val in test_vals: - tmpitem.set_value(val) - getattr(tmpitem, func_name)() - waiter.wait_for_state(val) + # only the openhab functions can be called without a value + if func_name.startswith('oh_'): + for val in test_vals: + tmpitem.set_value(val) + getattr(tmpitem, func_name)() + waiter.wait_for_state(val) @OpenhabTmpItem.create('Number', arg_name='oh_item') - def test_post_update_if(self, oh_item: OpenhabTmpItem): + def test_post_update_if(self, oh_item: OpenhabTmpItem) -> None: item = NumberItem.get_item(oh_item.name) with ItemWaiter(OpenhabItem.get_item(item.name)) as waiter: diff --git a/run/conf_testing/rules/openhab/test_items.py b/run/conf_testing/rules/openhab/test_items.py index 6ca730cc..b6a2e834 100644 --- a/run/conf_testing/rules/openhab/test_items.py +++ b/run/conf_testing/rules/openhab/test_items.py @@ -1,18 +1,20 @@ import asyncio +from datetime import datetime 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, GroupItem, NumberItem, StringItem +from HABApp.openhab.items import ColorItem, DatetimeItem, GroupItem, NumberItem, StringItem class OpenhabItems(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('ApiDoc', self.test_api) @@ -22,6 +24,7 @@ def __init__(self): self.add_test('TestColor', self.test_color) self.add_test('TestGroupFunction', self.test_group_func) self.add_test('TestSmallValues', self.test_small_float_values) + self.add_test('TestDateTimeValues', self.test_datetime_values) self.item_number = OpenhabTmpItem('Number') self.item_switch = OpenhabTmpItem('Switch') @@ -29,7 +32,7 @@ def __init__(self): self.item_group = OpenhabTmpItem('Group') self.item_string = OpenhabTmpItem('String') - def set_up(self): + def set_up(self) -> None: self.item_number.create_item(label='No metadata') self.item_switch.create_item() @@ -45,11 +48,11 @@ def set_up(self): self.openhab.set_metadata(self.item_string.name, 'ns2', 'v2', {'key2': 'value2'}) self.openhab.set_metadata(self.item_group.name, 'ns3', 'v3', {}) - def tear_down(self): + def tear_down(self) -> None: self.item_string.remove() self.item_switch.remove() - def test_existing(self): + def test_existing(self) -> None: item = StringItem.get_item('TestString') assert item.tags == frozenset(['TestTag']) assert item.groups == frozenset(['TestGroup']) @@ -57,7 +60,7 @@ def test_existing(self): assert item.metadata['meta1'].value == 'test' assert item.metadata['meta1'].config == Map({'key': 'value'}) - def test_api(self): + def test_api(self) -> None: self.openhab.get_item(self.item_string.name) self.openhab.get_item(self.item_number.name) @@ -68,7 +71,7 @@ def test_api(self): asyncio.run_coroutine_threadsafe(async_get_items(), loop).result() @OpenhabTmpItem.create('Number', arg_name='tmp_item') - def test_small_float_values(self, tmp_item: OpenhabTmpItem): + def test_small_float_values(self, tmp_item: OpenhabTmpItem) -> None: # https://github.com/spacemanspiff2007/HABApp/issues/425 item = NumberItem.get_item(tmp_item.name) assert item.value is None @@ -80,7 +83,7 @@ def test_small_float_values(self, tmp_item: OpenhabTmpItem): waiter.wait_for_state(value) @OpenhabTmpItem.use('String', arg_name='oh_item') - def test_tags(self, oh_item: OpenhabTmpItem): + def test_tags(self, oh_item: OpenhabTmpItem) -> None: oh_item.create_item(tags=['tag1', 'tag2']) item = StringItem.get_item(oh_item.name) @@ -95,7 +98,7 @@ def test_tags(self, oh_item: OpenhabTmpItem): @OpenhabTmpItem.use('String', arg_name='oh_item') @OpenhabTmpItem.create('Group', 'group1') @OpenhabTmpItem.create('Group', 'group2') - def test_groups(self, oh_item: OpenhabTmpItem): + def test_groups(self, oh_item: OpenhabTmpItem) -> None: grp1 = GroupItem.get_item('group1') grp2 = GroupItem.get_item('group2') @@ -121,7 +124,7 @@ def test_groups(self, oh_item: OpenhabTmpItem): @OpenhabTmpItem.use('Switch', arg_name='sw1') @OpenhabTmpItem.use('Switch', arg_name='sw2') @OpenhabTmpItem.use('Group', arg_name='grp') - def test_group_func(self, sw1: OpenhabTmpItem, sw2: OpenhabTmpItem, grp: OpenhabTmpItem): + def test_group_func(self, sw1: OpenhabTmpItem, sw2: OpenhabTmpItem, grp: OpenhabTmpItem) -> None: grp_item = grp.create_item(group_type='Switch', group_function='AND', group_function_params=['ON', 'OFF']) sw1_item = sw1.create_item(groups=[grp_item.name]) @@ -141,7 +144,7 @@ def test_group_func(self, sw1: OpenhabTmpItem, sw2: OpenhabTmpItem, grp: Openhab assert grp_item.value == 'OFF' @OpenhabTmpItem.create('Color', arg_name='oh_item') - def test_color(self, oh_item: OpenhabTmpItem): + def test_color(self, oh_item: OpenhabTmpItem) -> None: item = ColorItem.get_item(oh_item.name) with ItemWaiter(item) as waiter: @@ -156,5 +159,19 @@ def test_color(self, oh_item: OpenhabTmpItem): waiter.wait_for_state(tuple(target)) waiter.wait_for_attribs(hue=target[0], saturation=target[1], brightness=target[2]) + @OpenhabTmpItem.create('DateTime', arg_name='tmp_item') + def test_datetime_values(self, tmp_item: OpenhabTmpItem) -> None: + item = DatetimeItem.get_item(tmp_item.name) + + dt_system: SystemDateTime = SystemDateTime.now() + dt_instant: Instant = dt_system.instant() + dt_zoned: OffsetDateTime = dt_system.to_fixed_offset() + dt_datetime: datetime = dt_system.local().py_datetime() + + for value in (dt_system, dt_instant, dt_zoned): + with ItemWaiter(item) as waiter: + item.oh_post_update(value) + waiter.wait_for_state(dt_datetime) + OpenhabItems() diff --git a/run/conf_testing/rules/openhab/test_links.py b/run/conf_testing/rules/openhab/test_links.py index 339453f4..50be3052 100644 --- a/run/conf_testing/rules/openhab/test_links.py +++ b/run/conf_testing/rules/openhab/test_links.py @@ -6,18 +6,18 @@ class OpenhabLinkApi(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('AllLinks', self.wrap_async, self.api_get_links) - def wrap_async(self, coro, *args, **kwargs): + def wrap_async(self, coro, *args, **kwargs) -> None: # create valid data run_coro(coro(*args, **kwargs)) - def set_up(self): + def set_up(self) -> None: self.thing = self.openhab.get_thing(find_astro_sun_thing()) - async def api_get_links(self): + async def api_get_links(self) -> None: objs = await async_get_links() assert objs diff --git a/run/conf_testing/rules/openhab/test_max_sse_msg_size.py b/run/conf_testing/rules/openhab/test_max_sse_msg_size.py index e0adf221..43201aab 100644 --- a/run/conf_testing/rules/openhab/test_max_sse_msg_size.py +++ b/run/conf_testing/rules/openhab/test_max_sse_msg_size.py @@ -1,9 +1,9 @@ import logging -from HABAppTests import EventWaiter, ItemWaiter, OpenhabTmpItem, TestBaseRule +from HABAppTests import EventWaiter, ItemWaiter, TestBaseRule from HABApp.openhab.events import ItemStateChangedEventFilter -from HABApp.openhab.items import OpenhabItem +from HABApp.openhab.items import ImageItem log = logging.getLogger('HABApp.Tests') @@ -12,29 +12,31 @@ class TestMaxImageSize(TestBaseRule): """This rule is testing the OpenHAB item types by calling functions and checking values""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('Test Image Size', self.test_img_size) - def test_img_size(self): + def test_img_size(self) -> None: - with OpenhabTmpItem('Image') as item, ItemWaiter(OpenhabItem.get_item(item.name)) as item_waiter, \ - EventWaiter(item.name, ItemStateChangedEventFilter()) as event_waiter: + image = ImageItem.get_item('TestImage') + + with ItemWaiter(image) as item_waiter, \ + EventWaiter(image.name, ItemStateChangedEventFilter()) as event_waiter: k = 95 # test data size in kib _b1 = b'\xFF\xD8\xFF' + b'\x00' * (1024 - 3) + b'\x00' * (k - 1) * 1024 _b2 = b'\xFF\xD8\xFF' + b'\xFF' * (1024 - 3) + b'\x00' * (k - 1) * 1024 - item.oh_post_update(_b1) + image.oh_post_update(_b1) event_waiter.wait_for_event(value=_b1) item_waiter.wait_for_state(_b1) - item.oh_post_update(_b2) + image.oh_post_update(_b2) event_waiter.wait_for_event(value=_b2, old_value=_b1) item_waiter.wait_for_state(_b2) - log.info(f'Image with {len(_b2) / 1024 :.0f}k ok!') + log.info(f'Image with {len(_b2) / 1024:.0f}k ok!') TestMaxImageSize() diff --git a/run/conf_testing/rules/openhab/test_persistence.py b/run/conf_testing/rules/openhab/test_persistence.py index e5d5cb0a..057a2b56 100644 --- a/run/conf_testing/rules/openhab/test_persistence.py +++ b/run/conf_testing/rules/openhab/test_persistence.py @@ -14,7 +14,7 @@ class TestPersistenceBase(TestBaseRule): - def __init__(self, service_name: str, item_name: str): + def __init__(self, service_name: str, item_name: str) -> None: super().__init__() self.config.skip_on_failure = True @@ -23,7 +23,7 @@ def __init__(self, service_name: str, item_name: str): self.add_test(f'Persistence {service_name} available', self.test_service_available) - def set_up(self): + def set_up(self) -> None: i = NumberItem.get_item(self.item_name) if i.value is None: i.oh_post_update(0) @@ -46,11 +46,11 @@ def get_persistence_data(self, start_time: datetime | None, end_time: datetime | class TestRRD4j(TestPersistenceBase): - def __init__(self): - super().__init__('rrd4j', 'RRD4J_Item') + def __init__(self) -> None: + super().__init__('rrd4j', 'RRD4jItem') self.add_test('RRD4J get', self.test_get) - def test_get(self): + def test_get(self) -> None: now = datetime.now() d = self.get_persistence_data(now - timedelta(seconds=60), now) assert d.get_data() @@ -61,11 +61,11 @@ def test_get(self): class TestMapDB(TestPersistenceBase): - def __init__(self): - super().__init__('mapdb', 'RRD4J_Item') + def __init__(self) -> None: + super().__init__('mapdb', 'RRD4jItem') self.add_test('MapDB get', self.test_get) - def test_get(self): + def test_get(self) -> None: now = datetime.now() d = self.get_persistence_data(now - timedelta(seconds=60), now) assert d.get_data() @@ -76,26 +76,29 @@ def test_get(self): class TestInMemory(TestPersistenceBase): - def __init__(self): - super().__init__('inmemory', 'RRD4J_Item') + def __init__(self) -> None: + super().__init__('inmemory', 'InMemoryForecastItem') if Connections.get('openhab').context.version >= (4, 1): self.add_test('InMemory', self.test_in_memory) else: print('Skip "TestInMemory" because of no InMemoryDb') - def test_in_memory(self): - now = datetime.now().replace(microsecond=0) - t1 = now - timedelta(milliseconds=100) - t2 = now + timedelta(milliseconds=100) + def test_in_memory(self) -> None: + now = datetime.now().replace(microsecond=0) + timedelta(seconds=1) + t1 = now + timedelta(milliseconds=100) + t2 = now + timedelta(milliseconds=200) + t3 = now + timedelta(milliseconds=300) self.set_persistence_data(t1, 5) - self.set_persistence_data(now, 6) - self.set_persistence_data(t2, 7) - value = self.get_persistence_data(now - timedelta(milliseconds=200), now + timedelta(milliseconds=200)) + self.set_persistence_data(t2, 6) + self.set_persistence_data(t3, 7) + value = self.get_persistence_data(now - timedelta(milliseconds=1), t3 + timedelta(milliseconds=1)) objs = value.get_data() - assert objs == {t1.timestamp(): 5, now.timestamp(): 6, t2.timestamp(): 7} + + target = {t1.timestamp(): 5, t2.timestamp(): 6, t3.timestamp(): 7} + assert objs == target TestInMemory() diff --git a/run/conf_testing/rules/openhab/test_scheduler.py b/run/conf_testing/rules/openhab/test_scheduler.py new file mode 100644 index 00000000..8a8f2dd5 --- /dev/null +++ b/run/conf_testing/rules/openhab/test_scheduler.py @@ -0,0 +1,99 @@ +from datetime import timedelta +from time import monotonic, sleep + +from HABAppTests import OpenhabTmpItem, TestBaseRule + +from HABApp.core.events import ValueUpdateEventFilter +from HABApp.core.items.base_valueitem import datetime +from HABApp.openhab.items import DatetimeItem + + +class TestSchedulerOhInteraction(TestBaseRule): + def __init__(self) -> None: + super().__init__() + + self.add_test('Test scheduler oh_item once', self.test_scheduler_once) + self.add_test('Test scheduler oh_item every', self.test_scheduler_every) + self.add_test('Test scheduler oh_item countdown', self.test_scheduler_countdown, cb_async=False) + self.add_test('Test scheduler oh_item countdown async', self.test_scheduler_countdown, cb_async=True) + + @OpenhabTmpItem.create('DateTime', arg_name='tmp_item') + def test_scheduler_once(self, tmp_item: OpenhabTmpItem) -> None: + + item_states = [] + item = DatetimeItem.get_item(tmp_item.name) + listener = item.listen_event(lambda x: item_states.append(x), ValueUpdateEventFilter()) + + next_hour = datetime.now().replace(second=0, microsecond=0) + timedelta(hours=1) + + try: + job = self.run.once(next_hour, lambda: 1/0) + job.to_item(item) + job.cancel() + sleep(0.2) + finally: + listener.cancel() + + assert [v.value for v in item_states] == [next_hour, None] + + @OpenhabTmpItem.create('DateTime', arg_name='tmp_item') + def test_scheduler_every(self, tmp_item: OpenhabTmpItem) -> None: + + item_states = [] + item = DatetimeItem.get_item(tmp_item.name) + listener = item.listen_event(lambda x: item_states.append(x), ValueUpdateEventFilter()) + + next_hour = datetime.now().replace(second=0, microsecond=0) + timedelta(hours=1) + + try: + job = self.run.at(self.run.trigger.interval(next_hour, 7200), lambda: 1/0) + job.to_item(item) + job.pause() + job.resume() + job.cancel() + sleep(0.2) + finally: + listener.cancel() + + assert [v.value for v in item_states] == [next_hour, None, next_hour, None] + + @OpenhabTmpItem.create('DateTime', arg_name='tmp_item') + def test_scheduler_countdown(self, tmp_item: OpenhabTmpItem, cb_async: bool) -> None: + + calls = [] + item_states = [] + + item = DatetimeItem.get_item(tmp_item.name) + listener = item.listen_event(lambda x: item_states.append(x.value), ValueUpdateEventFilter()) + + def reset_func() -> None: + calls.append(monotonic()) + job.reset() + + async def async_reset_func() -> None: + reset_func() + + start = monotonic() + runs = 3 + try: + job = self.run.countdown(1, reset_func if not cb_async else async_reset_func) + job.to_item(item) + job.reset() + sleep(runs + 0.3) + job.cancel() + sleep(0.2) + finally: + listener.cancel() + + assert len(item_states) == 2 + 2 * runs + 1 + for obj in item_states[::2]: + assert obj is None + for obj in item_states[1::2]: + assert obj is not None + + assert len(calls) == runs + for i, ts in enumerate(calls): + assert 0.75 <= (ts - start) - i <= 1.15, (ts - start) - i + + +TestSchedulerOhInteraction() diff --git a/run/conf_testing/rules/openhab/test_things.py b/run/conf_testing/rules/openhab/test_things.py index 8c410d0a..84dfc8eb 100644 --- a/run/conf_testing/rules/openhab/test_things.py +++ b/run/conf_testing/rules/openhab/test_things.py @@ -8,21 +8,21 @@ class OpenhabThings(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('ApiDoc', self.test_api) self.add_test('Enable(API)', self.test_enabled_api) self.add_test('Enable(Obj)', self.test_enabled_obj) - def test_api(self): + def test_api(self) -> None: self.openhab.get_thing(find_astro_sun_thing()) - def test_enabled_api(self): + def test_enabled_api(self) -> None: uid = find_astro_sun_thing() assert self.oh.set_thing_enabled(uid, False) == 200 assert self.oh.set_thing_enabled(uid, True) == 200 - def test_enabled_obj(self): + def test_enabled_obj(self) -> None: thing = Thing.get_item(find_astro_sun_thing()) assert thing.is_enabled is True diff --git a/run/conf_testing/rules/openhab/test_transformations.py b/run/conf_testing/rules/openhab/test_transformations.py index b7f67adb..a70e8eb7 100644 --- a/run/conf_testing/rules/openhab/test_transformations.py +++ b/run/conf_testing/rules/openhab/test_transformations.py @@ -8,11 +8,11 @@ class OpenhabTransformations(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('TestMap', self.test_map) - def test_map(self): + def test_map(self) -> None: assert list(obj.keys()) diff --git a/run/conf_testing/rules/openhab_bugs.py b/run/conf_testing/rules/openhab_bugs.py index 6a12be3c..86b88940 100644 --- a/run/conf_testing/rules/openhab_bugs.py +++ b/run/conf_testing/rules/openhab_bugs.py @@ -16,11 +16,11 @@ class BugLinks(TestBaseRule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.add_test('delete item with link', self.create_meta) - def create_meta(self): + def create_meta(self) -> bool: astro_thing = find_astro_sun_thing() astro_channel = f'{astro_thing}:rise#start' name = get_random_name('DateTime') diff --git a/run/conf_testing/rules/test_mqtt.py b/run/conf_testing/rules/test_mqtt.py index 7052431a..db22735d 100644 --- a/run/conf_testing/rules/test_mqtt.py +++ b/run/conf_testing/rules/test_mqtt.py @@ -8,6 +8,7 @@ from HABApp.core.events import ValueUpdateEventFilter from HABApp.mqtt.events import MqttValueUpdateEventFilter from HABApp.mqtt.items import MqttItem, MqttPairItem +from HABApp.mqtt.util import MqttPublishOptions log = logging.getLogger('HABApp.MqttTestEvents') @@ -16,7 +17,7 @@ class TestMQTTEvents(TestBaseRule): """This rule is testing MQTT by posting values and checking the events""" - def __init__(self): + def __init__(self) -> None: super().__init__() self.config.skip_on_failure = True @@ -30,8 +31,9 @@ def __init__(self): self.add_test('MQTT item creation', self.test_mqtt_item_creation) self.add_test('MQTT pair item', self.test_mqtt_pair_item) + self.add_test('MQTT topic info', self.test_mqtt_topic_info) - def test_mqtt_pair_item(self): + def test_mqtt_pair_item(self) -> None: topic_read = 'test/topic_read' topic_write = 'test/topic_write' @@ -47,21 +49,21 @@ def test_mqtt_pair_item(self): self.mqtt.publish(topic_read, 'asdfasdf') item_waiter.wait_for_state(item) - def test_mqtt_events(self, event_type): + def test_mqtt_events(self, event_type) -> None: topic = 'test/event_topic' with EventWaiter(topic, event_type) as waiter: for data in self.mqtt_test_data: self.mqtt.publish(topic, data) waiter.wait_for_event(value=data) - def test_mqtt_state(self): + def test_mqtt_state(self) -> None: my_item = MqttItem.get_create_item('test/item_topic') with ItemWaiter(my_item) as waiter: for data in self.mqtt_test_data: my_item.publish(data) waiter.wait_for_state(data) - def test_mqtt_item_creation(self): + def test_mqtt_item_creation(self) -> None: topic = 'mqtt/item/creation' assert HABApp.core.Items.item_exists(topic) is False @@ -83,10 +85,16 @@ def test_mqtt_item_creation(self): HABApp.core.Items.pop_item(topic) - async def trigger_reconnect(self): + async def trigger_reconnect(self) -> None: connection = Connections.get('mqtt') connection.status._set_manual(ConnectionStatus.DISCONNECTED) connection.advance_status_task.start_if_not_running() + def test_mqtt_topic_info(self) -> None: + t = MqttPublishOptions('test/event_topic') + with EventWaiter(t.topic, ValueUpdateEventFilter()) as waiter: + t.publish('asdf') + waiter.wait_for_event(value='asdf') + TestMQTTEvents() diff --git a/setup.py b/setup.py index 4ce56476..f5aa02ee 100644 --- a/setup.py +++ b/setup.py @@ -1,30 +1,40 @@ -import typing +import re from pathlib import Path from setuptools import find_packages, setup +THIS_FILE = Path(__file__) + + # Load version number without importing HABApp def load_version() -> str: - version: typing.Dict[str, str] = {} - with open('src/HABApp/__version__.py') as fp: - exec(fp.read(), version) + version: dict[str, str] = {} + exec((THIS_FILE.parent / 'src/HABApp/__version__.py').read_text(), version) # noqa: S102 assert version['__version__'], version return version['__version__'] -def load_req() -> typing.List[str]: - with open('requirements_setup.txt') as f: - return f.readlines() +def load_req() -> list[str]: + + lines = THIS_FILE.with_name('requirements_setup.txt').read_text().splitlines() + print(lines) + + for i, line in enumerate(lines): + if m := re.search(r'([^/]+).git@\w', line): + lines[i] = f'{m.group(1):s} @ {line:s}' + print(f'Modified: {lines[i]}') + return lines __version__ = load_version() print(f'Version: {__version__}') -print('') +print() + # When we run tox tests we don't have these files available, so we skip them -readme = Path(__file__).with_name('readme.md') +readme = THIS_FILE.with_name('readme.md') long_description = '' if readme.is_file(): with readme.open('r', encoding='utf-8') as fh: @@ -53,17 +63,16 @@ def load_req() -> typing.List[str]: packages=find_packages('src', exclude=['tests*']), package_data={'HABApp': ['py.typed']}, install_requires=load_req(), - python_requires='>=3.8', + python_requires='>=3.10', classifiers=[ 'Development Status :: 4 - Beta', 'Framework :: AsyncIO', 'License :: OSI Approved :: Apache Software License', 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', 'Programming Language :: Python :: 3.10', 'Programming Language :: Python :: 3.11', 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Programming Language :: Python :: 3 :: Only', 'Topic :: Home Automation' ], diff --git a/src/HABApp/__check_dependency_packages__.py b/src/HABApp/__check_dependency_packages__.py index 72b0fbd5..2aff19cc 100644 --- a/src/HABApp/__check_dependency_packages__.py +++ b/src/HABApp/__check_dependency_packages__.py @@ -23,14 +23,12 @@ def get_dependencies() -> list[str]: 'ujson', 'immutables', 'javaproperties', - 'msgspec', - 'pendulum', 'typing-extensions', ] -def check_dependency_packages(): +def check_dependency_packages() -> None: """Imports all dependencies and reports failures""" missing: dict[str, ModuleNotFoundError] = {} diff --git a/src/HABApp/__cmd_args__.py b/src/HABApp/__cmd_args__.py index c46e37cd..9a8588d1 100644 --- a/src/HABApp/__cmd_args__.py +++ b/src/HABApp/__cmd_args__.py @@ -3,7 +3,6 @@ import os import sys import time -import typing from pathlib import Path @@ -74,7 +73,7 @@ def parse_args(passed_args=None) -> argparse.Namespace: return args -def find_config_folder(arg_config_path: typing.Optional[Path]) -> Path: +def find_config_folder(arg_config_path: Path | None) -> Path: global CONFIG_FILE if arg_config_path is None: diff --git a/src/HABApp/__debug_info__.py b/src/HABApp/__debug_info__.py index e39d69f1..f738afcf 100644 --- a/src/HABApp/__debug_info__.py +++ b/src/HABApp/__debug_info__.py @@ -31,6 +31,6 @@ def get_debug_info() -> str: return ret -def print_debug_info(): +def print_debug_info() -> None: print(f'Debug information\n{"-" * 80}') print(get_debug_info()) diff --git a/src/HABApp/__main__.py b/src/HABApp/__main__.py index 4fe7d3c9..5ac3284f 100644 --- a/src/HABApp/__main__.py +++ b/src/HABApp/__main__.py @@ -1,7 +1,6 @@ import asyncio import logging import sys -import typing import HABApp from HABApp.__cmd_args__ import find_config_folder, parse_args @@ -9,7 +8,7 @@ from HABApp.__splash_screen__ import show_screen -def main() -> typing.Union[int, str]: +def main() -> int | str: show_screen() @@ -27,12 +26,12 @@ def main() -> typing.Union[int, str]: # see if we have user code (e.g. for additional logging configuration or additional setup) try: - import HABAppUser + import HABAppUser # noqa: F401 except ModuleNotFoundError: pass # Shutdown handler for graceful shutdown - HABApp.runtime.shutdown.register_signal_handler() + HABApp.core.shutdown.register_signal_handler() app = HABApp.runtime.Runtime() HABApp.core.const.loop.create_task(app.start(cfg_folder)) diff --git a/src/HABApp/__splash_screen__.py b/src/HABApp/__splash_screen__.py index 709f25cc..edec1578 100644 --- a/src/HABApp/__splash_screen__.py +++ b/src/HABApp/__splash_screen__.py @@ -3,7 +3,7 @@ from HABApp.__version__ import __version__ -def show_screen(): +def show_screen() -> None: text = r''' _ _ _ ____ _ | | | | / \ | __ ) / \ _ __ _ __ diff --git a/src/HABApp/__version__.py b/src/HABApp/__version__.py index f3be767d..d2625002 100644 --- a/src/HABApp/__version__.py +++ b/src/HABApp/__version__.py @@ -10,4 +10,4 @@ # Development versions contain the DEV-COUNTER postfix: # - 24.01.0.DEV-1 -__version__ = '24.08.1' +__version__ = '24.11.0' diff --git a/src/HABApp/config/debug.py b/src/HABApp/config/debug.py new file mode 100644 index 00000000..3ed92764 --- /dev/null +++ b/src/HABApp/config/debug.py @@ -0,0 +1,92 @@ +import faulthandler +import logging +import signal +from asyncio import sleep +from datetime import datetime +from typing import Final, TextIO + +import HABApp +from HABApp.config.logging import rotate_file +from HABApp.core.asyncio import create_task +from HABApp.core.wrapper import log_exception + + +TRACEBACK_FILE: TextIO | None = None + + +def setup_debug() -> None: + global TRACEBACK_FILE + + debug = HABApp.CONFIG.habapp.debug + periodic_tb_cfg = debug.periodic_traceback + event_loop_cfg = debug.watch_event_loop + tb_on_shutdown_signal = debug.traceback_on_shutdown_signal + + if not event_loop_cfg.enabled and not tb_on_shutdown_signal and not periodic_tb_cfg.enabled: + return None + + file: Final = HABApp.CONFIG.directories.logging / 'HABApp_traceback.log' + logging.getLogger('HABApp').info(f'Dumping traceback to {file}') + + rotate_file(file, 3) + + TRACEBACK_FILE = file.open('a') + HABApp.core.shutdown.register(TRACEBACK_FILE.close, last=True, msg='Closing traceback file') + + if periodic_tb_cfg.enabled: + task = create_task( + dump_traceback_task( + int(periodic_tb_cfg.delay.total_seconds()), + int(periodic_tb_cfg.interval.total_seconds()) + ), + name='DumpTracebackTask' + ) + HABApp.core.shutdown.register(task.cancel, msg='Stopping traceback task') + + if tb_on_shutdown_signal: + TRACEBACK_FILE.write('Dumping on shutdown signal\n') + TRACEBACK_FILE.flush() + + faulthandler.register(signal.SIGINT, TRACEBACK_FILE, all_threads=True) + faulthandler.register(signal.SIGTERM, TRACEBACK_FILE, all_threads=True) + + if event_loop_cfg.enabled: + task = create_task( + watch_event_loop_task( + sleep_secs=int(event_loop_cfg.reset_every.total_seconds()), + timeout_secs=int(event_loop_cfg.timeout.total_seconds()), + ), + name='WatchEventLoopTask' + ) + HABApp.core.shutdown.register(task.cancel, msg='Stopping WatchEventLoopTask') + + +@log_exception +async def dump_traceback_task(delay: int, interval: int) -> None: + + await sleep(0.2) + TRACEBACK_FILE.write('Dumping traceback\n') + TRACEBACK_FILE.write(f'Start: {datetime.now()}\n') + TRACEBACK_FILE.write(f'Delay: {delay:d}s Interval: {interval:d}s\n') + TRACEBACK_FILE.write(f'\n{"-" * 80}\n') + TRACEBACK_FILE.flush() + + await sleep(delay) + + while True: + TRACEBACK_FILE.write(f'{datetime.now()}\n\n') + faulthandler.dump_traceback(TRACEBACK_FILE, all_threads=True) + TRACEBACK_FILE.write(f'\n{"-" * 80}\n') + + await sleep(interval) + + +@log_exception +async def watch_event_loop_task(sleep_secs: int, timeout_secs: int) -> None: + + TRACEBACK_FILE.write(f'Watching event loop\nReset: {sleep_secs:d}s Timeout: {timeout_secs:d}s\n\n') + TRACEBACK_FILE.flush() + + while True: + faulthandler.dump_traceback_later(timeout_secs, file=TRACEBACK_FILE, exit=True) + await sleep(sleep_secs) diff --git a/src/HABApp/config/loader.py b/src/HABApp/config/loader.py index 26a1019c..55b3fbf8 100644 --- a/src/HABApp/config/loader.py +++ b/src/HABApp/config/loader.py @@ -1,7 +1,6 @@ import logging import logging.config from pathlib import Path -from typing import List import eascheduler import pydantic @@ -9,17 +8,18 @@ import HABApp from HABApp import __version__ from HABApp.config.config import CONFIG -from HABApp.config.logging import HABAppQueueHandler +from HABApp.config.logging import HABAppQueueHandler, load_logging_file +from .debug import setup_debug from .errors import AbsolutePathExpected, InvalidConfigError -from .logging import create_default_logfile, get_logging_dict, inject_log_buffer, rotate_files +from .logging import create_default_logfile, get_logging_dict from .logging.buffered_logger import BufferedLogger log = logging.getLogger('HABApp.Config') -def load_config(config_folder: Path): +def load_config(config_folder: Path) -> None: CONFIG.set_file_path(config_folder / 'config.yml') @@ -40,6 +40,8 @@ def load_config(config_folder: Path): if not loaded_logging: load_logging_cfg(logging_cfg_path) + setup_debug() + # Watch folders, so we can reload the config on the fly filter = HABApp.core.files.watcher.FileEndingFilter('.yml') watcher = HABApp.core.files.watcher.AggregatingAsyncEventHandler( @@ -47,15 +49,15 @@ def load_config(config_folder: Path): ) HABApp.core.files.watcher.add_folder_watch(watcher) - HABApp.runtime.shutdown.register_func(stop_queue_handlers, last=True, msg='Stopping logging threads') + HABApp.core.shutdown.register(stop_queue_handlers, last=True, msg='Stopping logging threads') CONFIG.habapp.logging.subscribe_for_changes(set_flush_delay) -def set_flush_delay(): +def set_flush_delay() -> None: HABAppQueueHandler.FLUSH_DELAY = CONFIG.habapp.logging.flush_every -async def config_files_changed(paths: List[Path]): +async def config_files_changed(paths: list[Path]) -> None: for path in paths: if path.name == 'config.yml': load_habapp_cfg() @@ -63,15 +65,18 @@ async def config_files_changed(paths: List[Path]): load_logging_cfg(path) -def load_habapp_cfg(do_print=False): +def load_habapp_cfg(do_print=False) -> None: + def error(text: str) -> None: + if do_print: + print(text) + else: + log.error(text) + try: CONFIG.load_config_file() except pydantic.ValidationError as e: for line in str(e).splitlines(): - if do_print: - print(line) - else: - log.error(line) + error(line) raise InvalidConfigError from None # check if folders exist and print warnings, maybe because of missing permissions @@ -80,17 +85,25 @@ def load_habapp_cfg(do_print=False): CONFIG.directories.create_folders() - log.debug(f'Local Timezone: {eascheduler.const.local_tz}') location = CONFIG.location eascheduler.set_location(location.latitude, location.longitude, location.elevation) + if not location.country: + log.warning('No country is set in the config file. Holidays will not be available.') + else: + try: + eascheduler.setup_holidays(location.country, location.subdivision if location.subdivision else None) + except Exception as e: + for line in str(e).splitlines(): + error(line) + log.debug('Loaded HABApp config') -QUEUE_HANDLER: List['HABAppQueueHandler'] = [] +QUEUE_HANDLER: list['HABAppQueueHandler'] = [] -def stop_queue_handlers(): +def stop_queue_handlers() -> None: for qh in QUEUE_HANDLER: qh.signal_stop() while QUEUE_HANDLER: @@ -98,17 +111,16 @@ def stop_queue_handlers(): qh.stop() -def load_logging_cfg(path: Path): +def load_logging_cfg(path: Path) -> None: + # If the logging file gets accidentally deleted we do nothing + if (logging_yaml := load_logging_file(path)) is None: + return None + # stop buffered handlers stop_queue_handlers() buf_log = BufferedLogger() - cfg = get_logging_dict(path, buf_log) - - if CONFIG.habapp.logging.use_buffer: - q_handlers = inject_log_buffer(cfg, buf_log) - else: - q_handlers = [] + cfg, q_handlers = get_logging_dict(logging_yaml, buf_log) # load prepared logging try: @@ -118,8 +130,6 @@ def load_logging_cfg(path: Path): log.error(f'Error loading logging config: {e}') raise InvalidConfigError from None - rotate_files() - # start buffered handlers for qh in q_handlers: QUEUE_HANDLER.append(qh) diff --git a/src/HABApp/config/logging/__init__.py b/src/HABApp/config/logging/__init__.py index b0c0e311..edc0c3da 100644 --- a/src/HABApp/config/logging/__init__.py +++ b/src/HABApp/config/logging/__init__.py @@ -1,7 +1,8 @@ from .handler import MidnightRotatingFileHandler, CompressedMidnightRotatingFileHandler +from .utils import rotate_file # isort: split -from .config import get_logging_dict, rotate_files, inject_log_buffer +from .config import load_logging_file, get_logging_dict, inject_queue_handler from .default_logfile import get_default_logfile, create_default_logfile from .queue_handler import HABAppQueueHandler diff --git a/src/HABApp/config/logging/buffered_logger.py b/src/HABApp/config/logging/buffered_logger.py index a9c96814..75959bf9 100644 --- a/src/HABApp/config/logging/buffered_logger.py +++ b/src/HABApp/config/logging/buffered_logger.py @@ -1,12 +1,11 @@ import logging -from typing import List, Tuple class BufferedLogger: - def __init__(self): - self._msgs: List[Tuple[int, str]] = [] + def __init__(self) -> None: + self._msgs: list[tuple[int, str]] = [] - def _log(self, lvl: int, msg: str): + def _log(self, lvl: int, msg: str) -> None: self._msgs.append((lvl, msg)) def debug(self, msg: str) -> 'BufferedLogger': @@ -25,7 +24,7 @@ def error(self, msg: str) -> 'BufferedLogger': self._log(logging.ERROR, msg) return self - def flush(self, logger: logging.Logger): + def flush(self, logger: logging.Logger) -> None: for lvl, msg in self._msgs: logger.log(lvl, msg) self._msgs.clear() diff --git a/src/HABApp/config/logging/config.py b/src/HABApp/config/logging/config.py index da9ca9cc..fdd2f3e6 100644 --- a/src/HABApp/config/logging/config.py +++ b/src/HABApp/config/logging/config.py @@ -1,71 +1,44 @@ import logging import logging.config -from logging.handlers import RotatingFileHandler, TimedRotatingFileHandler from pathlib import Path -from typing import Any, Dict, List, Optional +from queue import Queue +from typing import Any from easyconfig.yaml import yaml_safe as _yaml_safe -import HABApp from HABApp.config.config import CONFIG from HABApp.config.errors import AbsolutePathExpected +from HABApp.config.logging import rotate_file +from HABApp.core.const.const import PYTHON_312, PYTHON_313 +from HABApp.core.const.log import TOPIC_EVENTS from .buffered_logger import BufferedLogger from .queue_handler import HABAppQueueHandler, SimpleQueue -def remove_memory_handler_from_cfg(cfg: dict, log: BufferedLogger): - # find memory handlers - m_handlers = {} - for handler, handler_cfg in cfg.get('handlers', {}).items(): - if handler_cfg.get('class', '') == 'logging.handlers.MemoryHandler': - log.error(f'"logging.handlers.MemoryHandler" is no longer required. Please remove from config ({handler})!') - if 'target' in handler_cfg: - m_handlers[handler] = handler_cfg['target'] - - # remove them from config - for h_name in m_handlers: - cfg['handlers'].pop(h_name) - log.warning(f'Removed {h_name:s} from handlers') - - # replace handlers in logger with target - for logger_name, logger_cfg in cfg.get('loggers', {}).items(): - logger_handlers = logger_cfg.get('handlers', []) - for i, logger_handler in enumerate(logger_handlers): - replacement_handler = m_handlers.get(logger_handler) - if replacement_handler is None: - continue - log.warning(f'Replaced {logger_handler} with {replacement_handler} for logger {logger_name}') - logger_handlers[i] = replacement_handler - - -def get_logging_dict(path: Path, log: BufferedLogger) -> Optional[dict]: - # config gets created on startup - if it gets deleted we do nothing here - if not path.is_file(): - return None - - with path.open('r', encoding='utf-8') as file: - cfg: Dict[str, Any] = _yaml_safe.load(file) +def fix_old_logger_location(handlers_cfg: dict, log: BufferedLogger) -> None: + src = 'HABApp.core.lib.handler.MidnightRotatingFileHandler' + dst = 'HABApp.config.logging.MidnightRotatingFileHandler' # fix filenames - for handler, handler_cfg in cfg.get('handlers', {}).items(): + for handler, cfg in handlers_cfg.items(): # migrate handler - if handler_cfg.get('class', '-') == 'HABApp.core.lib.handler.MidnightRotatingFileHandler': - dst = 'HABApp.config.logging.MidnightRotatingFileHandler' - handler_cfg['class'] = dst - log.warning(f'Replaced class for handler "{handler:s}" with {dst}') + if cfg.get('class', '-') == src: + cfg['class'] = dst + log.warning(f'Replaced class for handler "{handler:s}" with {dst:s}') + - if 'filename' not in handler_cfg: +def fix_log_filenames(handlers_cfg: dict) -> None: + for cfg in handlers_cfg.values(): + if (filename := cfg.get('filename')) is None: continue # fix encoding for FileHandlers - we always log utf-8 - if 'file' in handler_cfg.get('class', '').lower(): - enc = handler_cfg.get('encoding', '') - if enc != 'utf-8': - handler_cfg['encoding'] = 'utf-8' + if 'file' in cfg.get('class', '').lower() and cfg.get('encoding', '') != 'utf-8': + cfg['encoding'] = 'utf-8' # make Filenames absolute path in the log folder if not specified - p = Path(handler_cfg['filename']) + p = Path(filename) if not p.is_absolute(): # Our log folder ist not yet converted to path -> it is not loaded if not CONFIG.directories.logging.is_absolute(): @@ -73,65 +46,72 @@ def get_logging_dict(path: Path, log: BufferedLogger) -> Optional[dict]: # Use defined parent folder p = (CONFIG.directories.logging / p).resolve() - handler_cfg['filename'] = str(p) + cfg['filename'] = str(p) - # remove memory handlers - remove_memory_handler_from_cfg(cfg, log) - # make file version optional for config file - if 'version' not in cfg: - cfg['version'] = 1 - else: - log.warning('Entry "version" is no longer required in the logging configuration file') +def remove_memory_handler_from_cfg(handlers_cfg: dict, loggers_cfg: dict, log: BufferedLogger) -> None: + # find memory handlers + memory_targets = {} + for handler, handler_cfg in handlers_cfg.items(): + if handler_cfg.get('class', '') == 'logging.handlers.MemoryHandler': + log.error(f'"logging.handlers.MemoryHandler" is no longer required. Please remove from config ({handler})!') + if 'target' in handler_cfg: + memory_targets[handler] = handler_cfg['target'] - # Allow the user to set his own logging levels (with aliases) - for level, alias in cfg.pop('levels', {}).items(): - if not isinstance(level, int): - level = logging._nameToLevel[level] - logging.addLevelName(level, str(alias)) - log.debug(f'Added custom Level "{alias!s}" ({level})') + # remove them from config + for h_name in memory_targets: + handlers_cfg.pop(h_name) + log.warning(f'Removed {h_name:s} from handlers') - return cfg + # replace handlers in logger with target + for logger_name, logger_cfg in loggers_cfg.items(): + logger_handlers = logger_cfg.get('handlers', []) + for i, logger_handler in enumerate(logger_handlers): + if (replacement_handler := memory_targets.get(logger_handler)) is None: + continue + log.warning(f'Replaced {logger_handler} with {replacement_handler} for logger {logger_name}') + logger_handlers[i] = replacement_handler -def rotate_files(): - for wr in logging._handlerList: - handler = wr() # weakref -> call it to get object +def load_logging_file(path: Path) -> dict[str, Any] | None: + if not path.is_file(): + return None + + with path.open('r', encoding='utf-8') as file: + cfg: dict[str, Any] = _yaml_safe.load(file) + return cfg - # only rotate these types - if not isinstance(handler, (RotatingFileHandler, TimedRotatingFileHandler)): - continue - # Rotate only if files have content - logfile = Path(handler.baseFilename) - if not logfile.is_file() or logfile.stat().st_size <= 10: +def rotate_handler_files(handlers_cfg: dict) -> None: + for cfg in handlers_cfg.values(): + if (filename := cfg.get('filename')) is None: continue + if (backup_count := cfg.get('backupCount')) is None: + continue + file = Path(filename) - try: - handler.acquire() - handler.flush() - handler.doRollover() - except Exception as e: - HABApp.core.wrapper.process_exception(rotate_files, e) - finally: - handler.release() + # If the file is empty we do not rotate + if not file.is_file() or file.stat().st_size <= 10: # noqa: PLR2004 + continue + rotate_file(file, backup_count) -def inject_log_buffer(cfg: dict, log: BufferedLogger): - from HABApp.core.const.log import TOPIC_EVENTS - handler_cfg = cfg.setdefault('handlers', {}) +def inject_queue_handler(handlers_cfg: dict, loggers_cfg: dict, log: BufferedLogger) -> list[HABAppQueueHandler]: + if not CONFIG.habapp.logging.use_buffer: + return [] prefix = 'HABAppQueue_' # Check that the prefix is unique - for handler_name in handler_cfg: + for handler_name in handlers_cfg: if handler_name.startswith(prefix): - raise ValueError(f'Handler may not start with {prefix:s}') + msg = f'Handler may not start with {prefix:s}' + raise ValueError(msg) # replace the event logs with the buffered one buffered_handlers = {} - for log_name, log_cfg in cfg.get('loggers', {}).items(): + for log_name, log_cfg in loggers_cfg.items(): if not log_name.startswith(TOPIC_EVENTS): continue _handlers = {n: f'{prefix}{n}' for n in log_cfg['handlers']} @@ -146,14 +126,54 @@ def inject_log_buffer(cfg: dict, log: BufferedLogger): if not buffered_handlers: return [] - handler_cfg = cfg.setdefault('handlers', {}) - q_handlers: List[HABAppQueueHandler] = [] + q_handlers: list[HABAppQueueHandler] = [] for handler_name, buffered_handler_name in buffered_handlers.items(): - q: SimpleQueue = SimpleQueue() - handler_cfg[buffered_handler_name] = {'class': 'logging.handlers.QueueHandler', 'queue': q} + # https://github.com/python/cpython/issues/124653 + if PYTHON_313: + q: SimpleQueue = SimpleQueue() + elif PYTHON_312: + q = Queue() + else: + q: SimpleQueue = SimpleQueue() + handlers_cfg[buffered_handler_name] = {'class': 'logging.handlers.QueueHandler', 'queue': q} qh = HABAppQueueHandler(q, handler_name, f'LogBuffer{handler_name:s}') q_handlers.append(qh) return q_handlers + + +def process_custom_levels(cfg: dict[str, Any], log: BufferedLogger) -> None: + for level, alias in cfg.pop('levels', {}).items(): + if not isinstance(level, int): + # noinspection PyProtectedMember + level = logging._nameToLevel[level] # noqa: PLW2901 + logging.addLevelName(level, str(alias)) + log.debug(f'Added custom log level "{alias!s}" ({level})') + + +def get_logging_dict(cfg: dict[str, Any] | None, + log: BufferedLogger | logging.Logger) -> tuple[dict[str, Any], list[HABAppQueueHandler]]: + + # make file version optional for config file + if 'version' in cfg: + log.warning('Entry "version" is no longer required in the logging configuration file') + else: + cfg['version'] = 1 + + handlers_cfg = cfg.get('handlers', {}) + loggers_cfg = cfg.get('loggers', {}) + + fix_old_logger_location(handlers_cfg, log) + fix_log_filenames(handlers_cfg) + remove_memory_handler_from_cfg(handlers_cfg, loggers_cfg, log) + + # Rotate files before opening the handlers + rotate_handler_files(handlers_cfg) + + # Allow the user to set his own logging levels (with aliases) + process_custom_levels(cfg, log) + + q_handler = inject_queue_handler(handlers_cfg, loggers_cfg, log) + return cfg, q_handler diff --git a/src/HABApp/config/logging/handler.py b/src/HABApp/config/logging/handler.py index b1a90cea..4982199e 100644 --- a/src/HABApp/config/logging/handler.py +++ b/src/HABApp/config/logging/handler.py @@ -1,20 +1,24 @@ import gzip import shutil from datetime import date, datetime +from logging import LogRecord from logging.handlers import RotatingFileHandler from pathlib import Path +from typing_extensions import override + class MidnightRotatingFileHandler(RotatingFileHandler): """A rotating file handler that checks once after midnight if the configured size has been exceeded and then rotates the file """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.last_check: date = datetime.now().date() - def shouldRollover(self, record): + @override + def shouldRollover(self, record: LogRecord) -> int: date = datetime.now().date() if date == self.last_check: return 0 @@ -27,7 +31,7 @@ class CompressedMidnightRotatingFileHandler(MidnightRotatingFileHandler): """ - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: self.namer = self.compressed_namer self.rotator = self.compressed_rotator super().__init__(*args, **kwargs) @@ -35,7 +39,7 @@ def __init__(self, *args, **kwargs): def compressed_namer(self, default_name: str) -> str: return default_name + '.gz' - def compressed_rotator(self, source: str, dest: str): + def compressed_rotator(self, source: str, dest: str) -> None: src = Path(source) with src.open('rb') as f_in, gzip.open(dest, 'wb') as f_out: shutil.copyfileobj(f_in, f_out) diff --git a/src/HABApp/config/logging/queue_handler.py b/src/HABApp/config/logging/queue_handler.py index d0e9737b..1560768a 100644 --- a/src/HABApp/config/logging/queue_handler.py +++ b/src/HABApp/config/logging/queue_handler.py @@ -2,7 +2,7 @@ from queue import Empty, SimpleQueue from threading import Lock, Thread from time import sleep -from typing import Final, Optional +from typing import Final import HABApp @@ -18,17 +18,18 @@ class HABAppQueueHandler: FLUSH_DELAY: float = CONFIG.habapp.logging.flush_every - def __init__(self, queue: SimpleQueue, handler_name: str, thread_name: str): - self._handler: Optional[logging.Handler] = None + def __init__(self, queue: SimpleQueue, handler_name: str, thread_name: str) -> None: + self._handler: logging.Handler | None = None self._handler_name: Final = handler_name self._queue: Final = queue self._name: Final = thread_name - self._thread: Optional[Thread] = None + self._thread: Thread | None = None def start(self) -> None: with LOCK: if self._thread is not None: - raise RuntimeError('Thread can only be started once!') + msg = 'Thread can only be started once!' + raise RuntimeError(msg) # resolve handler self._handler = logging._handlers[self._handler_name] @@ -37,7 +38,7 @@ def start(self) -> None: thread.start() - def signal_stop(self): + def signal_stop(self) -> None: self._queue.put_nowait(None) def stop(self) -> None: @@ -48,7 +49,7 @@ def stop(self) -> None: self.signal_stop() thread.join() - def _worker(self): + def _worker(self) -> None: try: log.debug(f'{self._name} thread running') diff --git a/src/HABApp/config/logging/utils.py b/src/HABApp/config/logging/utils.py new file mode 100644 index 00000000..ed9f7256 --- /dev/null +++ b/src/HABApp/config/logging/utils.py @@ -0,0 +1,25 @@ +from pathlib import Path + + +def _get_file_name(file: Path, nr: int) -> Path: + if nr < 0: + raise ValueError() + + if nr: + return file.with_name(f'{file.name:s}.{nr:d}') + return file + + +def rotate_file(file: Path, backup_count: int) -> None: + if not isinstance(backup_count, int): + raise TypeError() + + _get_file_name(file, backup_count).unlink(missing_ok=True) + for i in range(backup_count - 1, -1, -1): + src = _get_file_name(file, i) + if not src.is_file(): + continue + + dst = _get_file_name(file, i + 1) + dst.unlink(missing_ok=True) + src.rename(dst) diff --git a/src/HABApp/config/models/directories.py b/src/HABApp/config/models/directories.py index a6226fa3..c5c75148 100644 --- a/src/HABApp/config/models/directories.py +++ b/src/HABApp/config/models/directories.py @@ -1,7 +1,6 @@ import logging import sys from pathlib import Path -from typing import Optional from easyconfig import BaseModel from pydantic import Field, validator @@ -19,13 +18,13 @@ class DirectoriesConfig(BaseModel): rules: Path = Field(Path('rules'), description='Folder from which the rule files will be loaded') # Optional Folders - param: Optional[Path] = Field(Path('params'), description='Folder from which the parameter files will be loaded') - config: Optional[Path] = Field(Path('config'), description='Folder from which configuration files ' + param: Path | None = Field(Path('params'), description='Folder from which the parameter files will be loaded') + config: Path | None = Field(Path('config'), description='Folder from which configuration files ' '(e.g. for textual thing configuration) will be loaded') - lib: Optional[Path] = Field(Path('lib'), description='Folder where additional libraries can be placed') + lib: Path | None = Field(Path('lib'), description='Folder where additional libraries can be placed') @validator('*') - def ensure_folder(cls, value: Optional[Path]): + def ensure_folder(cls, value: Path | None): import HABApp.__cmd_args__ if value is None: @@ -43,7 +42,7 @@ def ensure_folder(cls, value: Optional[Path]): return value - def create_folders(self): + def create_folders(self) -> None: # create folder structure if it does not exist if not self.rules.is_dir(): diff --git a/src/HABApp/config/models/habapp.py b/src/HABApp/config/models/habapp.py index 25f05834..7a45fa3f 100644 --- a/src/HABApp/config/models/habapp.py +++ b/src/HABApp/config/models/habapp.py @@ -1,5 +1,8 @@ +from datetime import timedelta + from easyconfig import BaseModel -from pydantic import Field, conint +from pydantic import Field, conint, model_validator +from typing_extensions import Self class ThreadPoolConfig(BaseModel): @@ -8,7 +11,7 @@ class ThreadPoolConfig(BaseModel): Use only if you have experience developing asyncio applications! If the thread pool is disabled using blocking calls in functions can and will break HABApp''' - threads: conint(ge=1, le=16) = 10 + threads: conint(ge=1, le=32) = 10 '''Amount of threads to use for the executor''' @@ -20,8 +23,60 @@ class LoggingConfig(BaseModel): '''Wait time in seconds before the buffer gets flushed again when it was empty''' +class PeriodicTracebackDumpConfig(BaseModel): + """Periodically dump the traceback of all currently running threads into a file""" + + enabled: bool = Field(False, description='Enable or disable functionality') + + delay: timedelta = Field('PT30M', gt=timedelta(seconds=0), + description='Initial delay before the first traceback dump') + + interval: timedelta = Field('PT1H', gt=timedelta(seconds=0), + description='Interval to dump the traceback') + + +class WatchEventLoopConfig(BaseModel): + """Watch the asyncio event loop. If the loop is blocked dump the traceback of all running threads + and shut down HABApp""" + + enabled: bool = Field(False, description='Enable or disable functionality') + + reset_every: timedelta = Field('PT1M', alias='reset every', gt=timedelta(seconds=0), + description='Reset interval for the timeout') + + timeout: timedelta = Field('PT2M30S', gt=timedelta(seconds=0), + description='Timeout after which HABApp will shut down') + + @model_validator(mode='after') + def _check_values(self) -> Self: + # round to second + self.timeout = timedelta(seconds=round(self.timeout.total_seconds())) + self.reset_every = timedelta(seconds=round(self.reset_every.total_seconds())) + + if self.timeout <= self.reset_every: + msg = f'Timeout must be greater than reset time! {self.timeout} > {self.reset_every}' + raise ValueError(msg) + return self + + +class DebugConfig(BaseModel): + """Debugging options for HABApp""" + + periodic_traceback: PeriodicTracebackDumpConfig = Field( + alias='periodic traceback', default_factory=PeriodicTracebackDumpConfig) + + traceback_on_shutdown_signal: bool = Field( + False, alias='traceback on shutdown signal', + description='Dump the traceback of all currently running threads into a file when receiving a shutdown signal. ' + 'Not available on Windows!' + ) + + watch_event_loop: WatchEventLoopConfig = Field(alias='watch event loop', default_factory=WatchEventLoopConfig) + + class HABAppConfig(BaseModel): """HABApp internal configuration. Only change values if you know what you are doing!""" logging: LoggingConfig = Field(default_factory=LoggingConfig) thread_pool: ThreadPoolConfig = Field(default_factory=ThreadPoolConfig, alias='thread pool') + debug: DebugConfig = Field(default_factory=DebugConfig) diff --git a/src/HABApp/config/models/location.py b/src/HABApp/config/models/location.py index a89376af..439d1ed5 100644 --- a/src/HABApp/config/models/location.py +++ b/src/HABApp/config/models/location.py @@ -1,15 +1,14 @@ -import logging.config - from easyconfig import BaseModel from pydantic import Field -log = logging.getLogger('HABApp.Config') - - class LocationConfig(BaseModel): - """location where the instance is running. Is used to calculate Sunrise/Sunset.""" + """location where the instance is running. Is used to calculate Sunrise/Sunset and holidays.""" latitude: float = Field(default=0.0) longitude: float = Field(default=0.0) elevation: float = Field(default=0.0) + + country: str = Field(default='', description='ISO 3166-1 Alpha-2 country code') + subdivision: str = Field( + default='', description='The subdivision (e.g. state or province) as a ISO 3166-2 code or its alias') diff --git a/src/HABApp/config/models/mqtt.py b/src/HABApp/config/models/mqtt.py index 73b05501..776f5331 100644 --- a/src/HABApp/config/models/mqtt.py +++ b/src/HABApp/config/models/mqtt.py @@ -1,8 +1,9 @@ import logging import random import string +from collections.abc import Generator from pathlib import Path -from typing import Literal, Optional, Tuple +from typing import Literal import pydantic from easyconfig.models import BaseModel @@ -29,6 +30,7 @@ class Connection(BaseModel): password: str = '' tls: TLSSettings = Field(default_factory=TLSSettings) + # implemented 2024.02.0 @pydantic.model_validator(mode='before') @classmethod def _migrate_client_id(cls, data): @@ -42,18 +44,38 @@ def _migrate_client_id(cls, data): class Subscribe(BaseModel): qos: QOS = Field(default=0, description='Default QoS for subscribing') - topics: Tuple[Tuple[str, Optional[QOS]], ...] = Field(default=('#', )) - - @pydantic.field_validator('topics', mode='before') - def parse_topics(cls, v): - if not isinstance(v, (list, tuple, set)): - raise ValueError('must be a list') - ret = [] - for e in v: - if isinstance(e, str): - e = (e, None) - ret.append(tuple(e)) - return tuple(ret) + topics: tuple[str | tuple[str, QOS], ...] = Field(default=('#', 'topic/with/default/qos', ('topic/with/qos', 1))) + + def get_topic_qos(self) -> Generator[tuple[str, QOS], None, None]: + for obj in self.topics: + if isinstance(obj, str): + yield obj, self.qos + else: + yield obj + + # Implemented 2024.11.0 + @pydantic.model_validator(mode='before') + @classmethod + def _migrate_topics(cls, data): + if isinstance(data, dict) and (topics := data.get('topics', [])) is not None: + for i, topic_obj in enumerate(topics): + if not isinstance(topic_obj, list): + continue + topic, qos = topic_obj + if qos is not None: + continue + + log = logging.getLogger('HABApp.Config') + log.warning('Empty QoS is not longer allowed for subscribing to topics.') + log.warning('Specify QOS or remove empty entry, e.g from') + log.warning(f' - - {topic:s}') + log.warning(' - ') + log.warning('to') + log.warning(f' - {topic:s}') + + topics[i] = topic + + return data class Publish(BaseModel): diff --git a/src/HABApp/config/models/openhab.py b/src/HABApp/config/models/openhab.py index 466fd67b..5538b87d 100644 --- a/src/HABApp/config/models/openhab.py +++ b/src/HABApp/config/models/openhab.py @@ -1,4 +1,3 @@ -from typing import Union from easyconfig.models import BaseModel from pydantic import AnyHttpUrl, ByteSize, Field, TypeAdapter, field_validator @@ -9,7 +8,7 @@ class Ping(BaseModel): 'an update from HABApp and get the updated value back from openHAB ' 'in milliseconds') item: str = Field('HABApp_Ping', description='Name of the Numberitem') - interval: Union[int, float] = Field(10, description='Seconds between two pings', ge=0.1) + interval: int | float = Field(10, description='Seconds between two pings', ge=0.1) class General(BaseModel): diff --git a/src/HABApp/config/platform_defaults.py b/src/HABApp/config/platform_defaults.py index 0af96bba..91df0ce5 100644 --- a/src/HABApp/config/platform_defaults.py +++ b/src/HABApp/config/platform_defaults.py @@ -1,8 +1,7 @@ from pathlib import Path -from typing import Optional -def get_log_folder(default: Optional[Path] = None) -> Optional[Path]: +def get_log_folder(default: Path | None = None) -> Path | None: # As a default we log into the openHAB folder choices = ('/var/log/openhab', '/opt/openhab/userdata/logs') for choice in choices: @@ -13,5 +12,5 @@ def get_log_folder(default: Optional[Path] = None) -> Optional[Path]: return default -def is_openhabian(): +def is_openhabian() -> bool: return Path('/opt/openhabian').is_dir() diff --git a/src/HABApp/core/__init__.py b/src/HABApp/core/__init__.py index cf87263d..8870fd27 100644 --- a/src/HABApp/core/__init__.py +++ b/src/HABApp/core/__init__.py @@ -1,10 +1,8 @@ from HABApp.core import const from HABApp.core import lib from HABApp.core import errors - -# isort: split - from HABApp.core import asyncio +from HABApp.core import shutdown # isort: split diff --git a/src/HABApp/core/asyncio.py b/src/HABApp/core/asyncio.py index 3086250c..91a28783 100644 --- a/src/HABApp/core/asyncio.py +++ b/src/HABApp/core/asyncio.py @@ -5,40 +5,39 @@ from asyncio import run_coroutine_threadsafe as _run_coroutine_threadsafe from contextvars import ContextVar as _ContextVar from contextvars import Token as _Token +from typing import TYPE_CHECKING, Final from typing import Any as _Any -from typing import Callable, Final -from typing import Callable as _Callable -from typing import Coroutine as _Coroutine +from typing import ParamSpec as _ParamSpec from typing import TypeVar as _TypeVar from HABApp.core.const import loop -from HABApp.core.const.const import PYTHON_310 -if PYTHON_310: - from typing import ParamSpec as _ParamSpec -else: - from typing_extensions import ParamSpec as _ParamSpec +if TYPE_CHECKING: + from collections.abc import Callable + from collections.abc import Callable as _Callable + from collections.abc import Coroutine as _Coroutine + from types import TracebackType async_context = _ContextVar('async_ctx') class AsyncContext: - def __init__(self, value: str): + def __init__(self, value: str) -> None: self.value: Final = value self.token: _Token[str] | None = None self.parent: AsyncContext | None = None - def __enter__(self): + def __enter__(self) -> None: assert self.token is None, self self.parent = async_context.get(None) self.token = async_context.set(self.value) - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None: async_context.reset(self.token) - def __repr__(self): + def __repr__(self) -> str: parent: str = '' if self.parent: parent = f'{self.parent} -> ' @@ -50,7 +49,7 @@ def __init__(self, func: _Callable) -> None: super().__init__() self.func: _Callable = func - def __str__(self): + def __str__(self) -> str: return f'Function "{self.func.__name__}" may not be called from an async context!' @@ -98,12 +97,14 @@ def run_func_from_async(func: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwa if async_context.get(None) is not None: return func(*args, **kwargs) + # we are in a thread, that's why we can wait (and block) for the future future = _run_coroutine_threadsafe(_run_func_from_async_helper(func, *args, **kwargs), loop) - return future - # Doc build fails if we enable this - # TODO: Fix the Rule Runner - # return future.result() + return future.result() async def _run_func_from_async_helper(func: Callable[_P, _T], *args: _P.args, **kwargs: _P.kwargs) -> _T: - return func(*args, **kwargs) + token = async_context.set('run_func_from_async') + try: + return func(*args, **kwargs) + finally: + async_context.reset(token) diff --git a/src/HABApp/core/connections/base_connection.py b/src/HABApp/core/connections/base_connection.py index 2c40bba9..aed5bb29 100644 --- a/src/HABApp/core/connections/base_connection.py +++ b/src/HABApp/core/connections/base_connection.py @@ -1,7 +1,9 @@ from __future__ import annotations from asyncio import CancelledError -from typing import TYPE_CHECKING, Callable, Final, Literal +from collections.abc import Callable +from types import TracebackType +from typing import TYPE_CHECKING, Final, Literal import HABApp from HABApp.core.connections._definitions import ConnectionStatus, connection_log @@ -21,14 +23,14 @@ class AlreadyHandledException(Exception): class HandleExceptionInConnection: - def __init__(self, connection: BaseConnection, func_name: Callable): + def __init__(self, connection: BaseConnection, func_name: Callable) -> None: self._connection: Final = connection self._func_name: Final = func_name - def __enter__(self): + def __enter__(self) -> None: pass - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None): # no exception -> we exit gracefully if exc_type is None and exc_val is None: return True @@ -41,7 +43,7 @@ def __exit__(self, exc_type, exc_val, exc_tb): class BaseConnection: - def __init__(self, name: str): + def __init__(self, name: str) -> None: self.name: Final = name self.log: Final = connection_log.getChild(name) self.status: Final = StatusTransitions() @@ -85,10 +87,10 @@ def has_errors(self) -> bool: def handle_exception(self, func: Callable) -> HandleExceptionInConnection: return HandleExceptionInConnection(self, func) - def is_silent_exception(self, e: Exception): + def is_silent_exception(self, e: Exception) -> bool: return False - def process_exception(self, e: Exception, func: Callable | str | None): + def process_exception(self, e: Exception, func: Callable | str | None) -> None: self.set_error() if self.is_silent_exception(e): @@ -134,7 +136,7 @@ def register_plugin(self, obj: BaseConnectionPlugin, priority: int | Literal['fi self.log.debug(f'Added plugin {obj.plugin_name:s}') return self - def remove_plugin(self, obj: BaseConnectionPlugin): + def remove_plugin(self, obj: BaseConnectionPlugin) -> None: self.plugins.remove(obj) obj.plugin_connection = None @@ -143,7 +145,7 @@ def remove_plugin(self, obj: BaseConnectionPlugin): for to_rem in rem: cb_list.remove(to_rem) - async def _task_next_status(self): + async def _task_next_status(self) -> None: with HABApp.core.wrapper.ExceptionToHABApp(logger=self.log): # if we are currently running stop the task @@ -158,7 +160,7 @@ async def _task_next_status(self): self.advance_status_task.task = None - async def _task_plugin(self): + async def _task_plugin(self) -> None: status = self.status status_enum = status.status @@ -190,7 +192,7 @@ def clear_error(self): self.log.debug('Cleared error') self.status.error = False - def set_error(self): + def set_error(self) -> None: if self.status.error: self.log.debug('Error on connection status is already set') else: @@ -198,15 +200,15 @@ def set_error(self): self.log.debug('Set error on connection status') self.advance_status_task.start_if_not_running() - def status_from_setup_to_disabled(self): + def status_from_setup_to_disabled(self) -> None: self.status.from_setup_to_disabled() self.advance_status_task.start_if_not_running() - def status_from_connected_to_disconnected(self): + def status_from_connected_to_disconnected(self) -> None: self.status.from_connected_to_disconnected() self.advance_status_task.start_if_not_running() - def status_configuration_changed(self): + def status_configuration_changed(self) -> None: self.log.debug('Requesting setup') self.status.setup = True self.advance_status_task.start_if_not_running() @@ -223,7 +225,7 @@ def on_application_shutdown(self): self.advance_status_task.start_if_not_running() - def application_startup_complete(self): + def application_startup_complete(self) -> None: self.log.debug('Overview') for status, objs in self.plugin_callbacks.items(): if not objs: diff --git a/src/HABApp/core/connections/base_plugin.py b/src/HABApp/core/connections/base_plugin.py index e33f681b..c68f7e5c 100644 --- a/src/HABApp/core/connections/base_plugin.py +++ b/src/HABApp/core/connections/base_plugin.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Awaitable, Callable, Final, Generic, TypeVar +from collections.abc import Awaitable, Callable +from typing import TYPE_CHECKING, Any, Final, Generic, TypeVar from HABApp.core.lib import SingleTask @@ -13,7 +14,7 @@ class BaseConnectionPlugin(Generic[T]): - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__() if name is None: @@ -25,18 +26,18 @@ def __init__(self, name: str | None = None): self.plugin_name: Final = name self.plugin_callbacks: dict[str, PluginCallbackHandler] = {} - def on_application_shutdown(self): + def on_application_shutdown(self) -> None: pass class BaseConnectionPluginConnectedTask(BaseConnectionPlugin[T]): def __init__(self, task_coro: Callable[[], Awaitable[Any]], - task_name: str, name: str | None = None): + task_name: str, name: str | None = None) -> None: super().__init__(name) self.task: Final = SingleTask(task_coro, name=task_name) - async def on_connected(self): + async def on_connected(self) -> None: self.task.start() - async def on_disconnected(self): + async def on_disconnected(self) -> None: await self.task.cancel_wait() diff --git a/src/HABApp/core/connections/connection_task.py b/src/HABApp/core/connections/connection_task.py index 54f46d7b..13bbe228 100644 --- a/src/HABApp/core/connections/connection_task.py +++ b/src/HABApp/core/connections/connection_task.py @@ -1,7 +1,8 @@ from __future__ import annotations import logging -from typing import Any, Awaitable, Callable, Final +from collections.abc import Awaitable, Callable +from typing import Any, Final from HABApp.core.lib import SingleTask @@ -9,13 +10,13 @@ class PluginTask(SingleTask): def __init__(self, coro: Callable[[], Awaitable[Any]], name: str | None, logger: logging.Logger | None, - exception_handler: Callable[[Exception, Callable | str | None], Any]): + exception_handler: Callable[[Exception, Callable | str | None], Any]) -> None: super().__init__(coro, name) self.log: Final = logger self.exception_handler: Final = exception_handler - async def _task_wrap(self): + async def _task_wrap(self) -> None: if self.log is not None: self.log.debug(f'Task {self.name} start') diff --git a/src/HABApp/core/connections/manager.py b/src/HABApp/core/connections/manager.py index 5c133817..2cb523da 100644 --- a/src/HABApp/core/connections/manager.py +++ b/src/HABApp/core/connections/manager.py @@ -12,7 +12,7 @@ class ConnectionManager: - def __init__(self): + def __init__(self) -> None: self.connections: dict[str, BaseConnection] = {} def add(self, connection: T) -> T: @@ -31,19 +31,19 @@ def remove(self, name): raise ValueError() self.connections.pop(name) - async def on_application_shutdown(self): + async def on_application_shutdown(self) -> None: for c in self.connections.values(): c.on_application_shutdown() tasks = [t.advance_status_task.wait() for t in self.connections.values()] await asyncio.gather(*tasks) - def application_startup_complete(self): + def application_startup_complete(self) -> None: for c in self.connections.values(): with HABApp.core.wrapper.ExceptionToHABApp(logger=c.log): c.application_startup_complete() - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__}>' diff --git a/src/HABApp/core/connections/plugin_callback.py b/src/HABApp/core/connections/plugin_callback.py index 285b884d..a83e2f60 100644 --- a/src/HABApp/core/connections/plugin_callback.py +++ b/src/HABApp/core/connections/plugin_callback.py @@ -1,9 +1,10 @@ from __future__ import annotations import re +from collections.abc import Awaitable, Callable from dataclasses import dataclass from inspect import getmembers, iscoroutinefunction, signature -from typing import TYPE_CHECKING, Any, Awaitable, Callable +from typing import TYPE_CHECKING, Any from ._definitions import ConnectionStatus diff --git a/src/HABApp/core/connections/plugins/auto_reconnect.py b/src/HABApp/core/connections/plugins/auto_reconnect.py index 9c00de01..3b77bf4e 100644 --- a/src/HABApp/core/connections/plugins/auto_reconnect.py +++ b/src/HABApp/core/connections/plugins/auto_reconnect.py @@ -8,14 +8,14 @@ class WaitBetweenConnects: wait_max = 600 - def __init__(self): + def __init__(self) -> None: self.wait_time: int = 0 self.task: Task | None = None - def reset_wait(self): + def reset_wait(self) -> None: self.wait_time = 0 - async def wait(self): + async def wait(self) -> None: wait = self.wait_time wait = wait * 2 if wait <= 16 else wait * 1.5 wait = max(1, min(wait, self.wait_max)) @@ -30,7 +30,7 @@ async def wait(self): finally: self.task = None - def cancel(self): + def cancel(self) -> None: if task := self.task: task.cancel() @@ -38,14 +38,14 @@ def cancel(self): class AutoReconnectPlugin(BaseConnectionPlugin): _DEFAULT_PRIORITY = 110_000 - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__(name) self.waiter = WaitBetweenConnects() - def on_application_shutdown(self): + def on_application_shutdown(self) -> None: self.waiter.cancel() - async def on_online(self): + async def on_online(self) -> None: self.waiter.reset_wait() async def on_offline(self, connection: BaseConnection): diff --git a/src/HABApp/core/connections/plugins/state_to_event.py b/src/HABApp/core/connections/plugins/state_to_event.py index 03e0dd2a..fde52e56 100644 --- a/src/HABApp/core/connections/plugins/state_to_event.py +++ b/src/HABApp/core/connections/plugins/state_to_event.py @@ -12,17 +12,17 @@ class ConnectionStateToEventBusPlugin(BaseConnectionPlugin): _DEFAULT_PRIORITY = 100_000 - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__(name) self.__last_report = None - def __post_event(self, connection: BaseConnection): + def __post_event(self, connection: BaseConnection) -> None: if (status := connection.status.status.value) != self.__last_report: post_event(TOPIC_CONNECTIONS, HABAppConnectionStateEvent(connection.name, status)) self.__last_report = status - async def on_online(self, connection: BaseConnection): + async def on_online(self, connection: BaseConnection) -> None: self.__post_event(connection) - async def on_disconnected(self, connection: BaseConnection): + async def on_disconnected(self, connection: BaseConnection) -> None: self.__post_event(connection) diff --git a/src/HABApp/core/connections/status_transitions.py b/src/HABApp/core/connections/status_transitions.py index f02b8e6d..7d1848ac 100644 --- a/src/HABApp/core/connections/status_transitions.py +++ b/src/HABApp/core/connections/status_transitions.py @@ -4,7 +4,7 @@ class StatusTransitions: - def __init__(self): + def __init__(self) -> None: self.status = ConnectionStatus.STARTUP self.manual: ConnectionStatus | None = None @@ -24,18 +24,18 @@ def advance_status(self) -> ConnectionStatus | None: return status - def is_connecting_or_connected(self): + def is_connecting_or_connected(self) -> bool: return self.status in (ConnectionStatus.CONNECTING, ConnectionStatus.CONNECTED, ConnectionStatus.ONLINE) - def _set_manual(self, status: ConnectionStatus): + def _set_manual(self, status: ConnectionStatus) -> None: assert self.manual is None self.manual = status - def from_setup_to_disabled(self): + def from_setup_to_disabled(self) -> None: assert self.status == ConnectionStatus.SETUP self._set_manual(ConnectionStatus.DISABLED) - def from_connected_to_disconnected(self): + def from_connected_to_disconnected(self) -> None: assert self.status == ConnectionStatus.CONNECTED self._set_manual(ConnectionStatus.DISCONNECTED) @@ -76,7 +76,7 @@ def _next_step(self) -> ConnectionStatus: } return transitions.get(status) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {self.status} ' \ f'[{"x" if self.error else " "}] Error, ' \ f'[{"x" if self.setup else " "}] Setup>' diff --git a/src/HABApp/core/const/const.py b/src/HABApp/core/const/const.py index be57b4e2..fb904bf5 100644 --- a/src/HABApp/core/const/const.py +++ b/src/HABApp/core/const/const.py @@ -3,21 +3,21 @@ from enum import Enum from typing import Final +from whenever import Instant + class _MissingType(Enum): MISSING = object() - def __repr__(self): + def __repr__(self) -> str: return '' MISSING: Final = _MissingType.MISSING -STARTUP: Final = time.monotonic() +STARTUP_MONOTONIC: Final = time.monotonic() +STARTUP_INSTANT: Final = Instant.now() # Python Versions for feature control -PYTHON_38: Final = sys.version_info >= (3, 8) -PYTHON_39: Final = sys.version_info >= (3, 9) -PYTHON_310: Final = sys.version_info >= (3, 10) PYTHON_311: Final = sys.version_info >= (3, 11) PYTHON_312: Final = sys.version_info >= (3, 12) PYTHON_313: Final = sys.version_info >= (3, 13) diff --git a/src/HABApp/core/const/hints.py b/src/HABApp/core/const/hints.py index ffdf34bb..7ed8bfed 100644 --- a/src/HABApp/core/const/hints.py +++ b/src/HABApp/core/const/hints.py @@ -1,19 +1,11 @@ +from collections.abc import Awaitable as __Awaitable +from collections.abc import Callable as __Callable from typing import Any as __Any -from typing import Awaitable as __Awaitable -from typing import Callable as __Callable from typing import Protocol as __Protocol -from typing import Type as __Type +from typing import TypeAlias -from .const import PYTHON_310 as __IS_GE_PYTHON_310 - -if __IS_GE_PYTHON_310: - from typing import TypeAlias -else: - from typing import Final as TypeAlias - - -TYPE_ANY_CLASS_TYPE: TypeAlias = __Type[object] +TYPE_ANY_CLASS_TYPE: TypeAlias = type[object] TYPE_FUNC_ASYNC: TypeAlias = __Callable[..., __Awaitable[__Any]] TYPE_EVENT_CALLBACK: TypeAlias = __Callable[[__Any], __Any] diff --git a/src/HABApp/core/const/json.py b/src/HABApp/core/const/json.py index 0d09e3d2..ba904df1 100644 --- a/src/HABApp/core/const/json.py +++ b/src/HABApp/core/const/json.py @@ -1,6 +1,5 @@ -from typing import Any, Callable - -import msgspec +from collections.abc import Callable +from typing import Any try: @@ -11,7 +10,3 @@ import json load_json: Callable[[str], Any] = json.loads dump_json: Callable[[str], Any] = json.dumps - - -decode_struct = msgspec.json.decode -encode_struct = msgspec.json.encode diff --git a/src/HABApp/core/const/topics.py b/src/HABApp/core/const/topics.py index 0e040b91..9213c8d8 100644 --- a/src/HABApp/core/const/topics.py +++ b/src/HABApp/core/const/topics.py @@ -1,4 +1,4 @@ -from typing import Final, Tuple +from typing import Final TOPIC_INFOS: Final = 'HABApp.Infos' @@ -9,7 +9,7 @@ TOPIC_CONNECTIONS: Final = 'HABApp.Connections' -ALL_TOPICS: Tuple[str, ...] = ( +ALL_TOPICS: tuple[str, ...] = ( TOPIC_INFOS, TOPIC_WARNINGS, TOPIC_ERRORS, TOPIC_FILES, diff --git a/src/HABApp/core/errors.py b/src/HABApp/core/errors.py index fa87be13..04f892d4 100644 --- a/src/HABApp/core/errors.py +++ b/src/HABApp/core/errors.py @@ -11,13 +11,13 @@ def __init__(self, obj) -> None: class ItemNotFoundException(HABAppException): - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__(f'Item {name} does not exist!') self.name: str = name class ItemAlreadyExistsError(HABAppException): - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__(f'Item {name} does already exist and can not be added again!') self.name: str = name diff --git a/src/HABApp/core/events/__init__.py b/src/HABApp/core/events/__init__.py index 7b8b8022..4cc3abcc 100644 --- a/src/HABApp/core/events/__init__.py +++ b/src/HABApp/core/events/__init__.py @@ -1,5 +1,18 @@ from . import habapp_events -from .events import ComplexEventValue, ValueUpdateEvent, ValueChangeEvent, \ - ItemNoChangeEvent, ItemNoUpdateEvent -from .filter import NoEventFilter, OrFilterGroup, AndFilterGroup, ValueUpdateEventFilter, ValueChangeEventFilter, \ - EventFilter +from .events import ( + ComplexEventValue, + ItemNoChangeEvent, + ItemNoUpdateEvent, + ValueChangeEvent, + ValueCommandEvent, + ValueUpdateEvent, +) +from .filter import ( + AndFilterGroup, + EventFilter, + NoEventFilter, + OrFilterGroup, + ValueChangeEventFilter, + ValueCommandEventFilter, + ValueUpdateEventFilter, +) diff --git a/src/HABApp/core/events/events.py b/src/HABApp/core/events/events.py index 2260304a..fe3643b1 100644 --- a/src/HABApp/core/events/events.py +++ b/src/HABApp/core/events/events.py @@ -1,8 +1,8 @@ -from typing import Any, Final, Union +from typing import Any, Final class ComplexEventValue: - def __init__(self, value): + def __init__(self, value: Any) -> None: self.value: Any = value @@ -15,12 +15,12 @@ class ValueUpdateEvent: name: str value: Any - def __init__(self, name: str, value: Any): + def __init__(self, name: str, value: Any) -> None: self.name: Final = name self.value: Final = value - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' + def __repr__(self) -> str: + return f'<{self.__class__.__name__} name: {self.name:s}, value: {self.value}>' class ValueChangeEvent: @@ -34,43 +34,60 @@ class ValueChangeEvent: value: Any old_value: Any - def __init__(self, name: str, value: Any, old_value: Any): + def __init__(self, name: str, value: Any, old_value: Any) -> None: self.name: Final = name self.value: Final = value self.old_value: Final = old_value - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}, old_value: {self.old_value}>' + def __repr__(self) -> str: + return f'<{self.__class__.__name__} name: {self.name:s}, value: {self.value}, old_value: {self.old_value}>' + + +class ValueCommandEvent: + """ + :ivar str name: + :ivar Any value: + """ + + name: str + value: Any + + def __init__(self, name: str, value: Any) -> None: + self.name: Final = name + self.value: Final = value + + def __repr__(self) -> str: + return f'<{self.__class__.__name__} name: {self.name:s}, value: {self.value}>' class ItemNoChangeEvent: """ - :ivar str name: - :ivar Union[int, float] seconds: + :ivar str name: + :ivar int | float seconds: """ name: str - seconds: Union[int, float] + seconds: int | float - def __init__(self, name: str, seconds: Union[int, float]): + def __init__(self, name: str, seconds: int | float) -> None: self.name: Final = name self.seconds: Final = seconds - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, seconds: {self.seconds}>' + def __repr__(self) -> str: + return f'<{self.__class__.__name__} name: {self.name:s}, seconds: {self.seconds}>' class ItemNoUpdateEvent: """ :ivar str name: - :ivar Union[int, float] seconds: + :ivar int | float seconds: """ name: str - seconds: Union[int, float] + seconds: int | float - def __init__(self, name: str, seconds: Union[int, float]): + def __init__(self, name: str, seconds: int | float) -> None: self.name: Final = name self.seconds: Final = seconds - def __repr__(self): - return f'<{self.__class__.__name__} name: {self.name}, seconds: {self.seconds}>' + def __repr__(self) -> str: + return f'<{self.__class__.__name__} name: {self.name:s}, seconds: {self.seconds}>' diff --git a/src/HABApp/core/events/filter/__init__.py b/src/HABApp/core/events/filter/__init__.py index 1858f5d5..d5b5984c 100644 --- a/src/HABApp/core/events/filter/__init__.py +++ b/src/HABApp/core/events/filter/__init__.py @@ -1,4 +1,4 @@ -from .no_filter import NoEventFilter from .event import EventFilter -from .habapp_events import ValueUpdateEventFilter, ValueChangeEventFilter -from .groups import OrFilterGroup, AndFilterGroup +from .groups import AndFilterGroup, OrFilterGroup +from .habapp_events import ValueChangeEventFilter, ValueCommandEventFilter, ValueUpdateEventFilter +from .no_filter import NoEventFilter diff --git a/src/HABApp/core/events/filter/event.py b/src/HABApp/core/events/filter/event.py index 756d5b3e..6875d7aa 100644 --- a/src/HABApp/core/events/filter/event.py +++ b/src/HABApp/core/events/filter/event.py @@ -1,5 +1,5 @@ from inspect import isclass -from typing import Final, Optional +from typing import Final from typing import get_type_hints as typing_get_type_hints from HABApp.core.const import MISSING @@ -10,16 +10,16 @@ class EventFilter(EventFilterBase): """Triggers on event types and optionally on their values, too""" - def __init__(self, event_class: TYPE_ANY_CLASS_TYPE, **kwargs): + def __init__(self, event_class: TYPE_ANY_CLASS_TYPE, **kwargs) -> None: assert len(kwargs) < 3, 'EventFilter only allows up to two args that will be used to filter' assert isclass(event_class), f'Class for event required! Passed {event_class} ({type(event_class)})' self.event_class: Final = event_class # Property filters - self.attr_name1: Optional[str] = None + self.attr_name1: str | None = None self.attr_value1 = None - self.attr_name2: Optional[str] = None + self.attr_name2: str | None = None self.attr_value2 = None type_hints = typing_get_type_hints(event_class) diff --git a/src/HABApp/core/events/filter/groups.py b/src/HABApp/core/events/filter/groups.py index 44ebe8f4..7e1dc2bd 100644 --- a/src/HABApp/core/events/filter/groups.py +++ b/src/HABApp/core/events/filter/groups.py @@ -1,11 +1,11 @@ -from typing import Any, Tuple +from typing import Any -from HABApp.core.internals import HINT_EVENT_FILTER_OBJ, EventFilterBase +from HABApp.core.internals import EventFilterBase class EventFilterBaseGroup(EventFilterBase): - def __init__(self, *args: HINT_EVENT_FILTER_OBJ): - self.filters: Tuple[HINT_EVENT_FILTER_OBJ, ...] = args + def __init__(self, *args: EventFilterBase) -> None: + self.filters: tuple[EventFilterBase, ...] = args def trigger(self, event) -> bool: raise NotImplementedError() diff --git a/src/HABApp/core/events/filter/habapp_events.py b/src/HABApp/core/events/filter/habapp_events.py index 482dbeb4..edc3a2f8 100644 --- a/src/HABApp/core/events/filter/habapp_events.py +++ b/src/HABApp/core/events/filter/habapp_events.py @@ -1,15 +1,20 @@ from typing import Any from HABApp.core.const import MISSING -from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent +from HABApp.core.events import ValueChangeEvent, ValueCommandEvent, ValueUpdateEvent from HABApp.core.events.filter.event import TypeBoundEventFilter class ValueUpdateEventFilter(TypeBoundEventFilter): - def __init__(self, value: Any = MISSING): + def __init__(self, value: Any = MISSING) -> None: super().__init__(ValueUpdateEvent, value=value) class ValueChangeEventFilter(TypeBoundEventFilter): - def __init__(self, value: Any = MISSING, old_value: Any = MISSING): + def __init__(self, value: Any = MISSING, old_value: Any = MISSING) -> None: super().__init__(ValueChangeEvent, value=value, old_value=old_value) + + +class ValueCommandEventFilter(TypeBoundEventFilter): + def __init__(self, value: Any = MISSING) -> None: + super().__init__(ValueCommandEvent, value=value) diff --git a/src/HABApp/core/events/habapp_events.py b/src/HABApp/core/events/habapp_events.py index 71ccf7b4..f971561d 100644 --- a/src/HABApp/core/events/habapp_events.py +++ b/src/HABApp/core/events/habapp_events.py @@ -2,10 +2,10 @@ class __FileEventBase: - def __init__(self, name: str): + def __init__(self, name: str) -> None: self.name: str = name - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} filename: {self.name}>' @@ -30,12 +30,12 @@ class HABAppException: :ivar str traceback: traceback :ivar Exception exception: Exception """ - def __init__(self, func_name: str, exception: Exception, traceback: str): + def __init__(self, func_name: str, exception: Exception, traceback: str) -> None: self.func_name: str = func_name self.exception: Exception = exception self.traceback: str = traceback - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} func_name: {self.func_name}, exception: {self.exception}>' def to_str(self) -> str: @@ -49,9 +49,9 @@ class HABAppConnectionStateEvent: :ivar str connection: name of the connection :ivar str state: state of the connection """ - def __init__(self, connection: str, state: str): + def __init__(self, connection: str, state: str) -> None: self.connection: Final = connection self.state: Final = state - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} connection: {self.connection:s}, state: {self.state:s}>' diff --git a/src/HABApp/core/files/errors.py b/src/HABApp/core/files/errors.py index 4d4367a5..7b9c9a20 100644 --- a/src/HABApp/core/files/errors.py +++ b/src/HABApp/core/files/errors.py @@ -1,19 +1,19 @@ -from typing import Iterable as _Iterable +from collections.abc import Iterable as _Iterable class CircularReferenceError(Exception): - def __init__(self, stack: _Iterable[str]): + def __init__(self, stack: _Iterable[str]) -> None: self.stack = stack - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {" -> ".join(self.stack)}>' class DependencyDoesNotExistError(Exception): - def __init__(self, msg: str): + def __init__(self, msg: str) -> None: self.msg = msg - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {self.msg}>' diff --git a/src/HABApp/core/files/file/file.py b/src/HABApp/core/files/file/file.py index 02835b69..d2f187e8 100644 --- a/src/HABApp/core/files/file/file.py +++ b/src/HABApp/core/files/file/file.py @@ -1,8 +1,9 @@ from __future__ import annotations import logging +from collections.abc import Awaitable, Callable from pathlib import Path -from typing import Any, Awaitable, Callable +from typing import Any from HABApp.core.files.errors import AlreadyHandledFileError, CircularReferenceError, DependencyDoesNotExistError from HABApp.core.files.file.properties import FileProperties @@ -20,7 +21,7 @@ class HABAppFile: LOAD_FUNC: Callable[[str, Path], Awaitable[Any]] UNLOAD_FUNC: Callable[[str, Path], Awaitable[Any]] - def __init__(self, name: str, path: Path, properties: FileProperties): + def __init__(self, name: str, path: Path, properties: FileProperties) -> None: self.name: str = name self.path: Path = path @@ -28,7 +29,7 @@ def __init__(self, name: str, path: Path, properties: FileProperties): self.properties: FileProperties = properties log.debug(f'{self.name} added') - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {self.name} state: {self.state}>' def set_state(self, new_state: FileState): @@ -129,7 +130,7 @@ async def unload(self): self.set_state(FileState.REMOVED) return None - def file_changed(self, file: HABAppFile): + def file_changed(self, file: HABAppFile) -> None: name = file.name if name in self.properties.reloads_on: self.set_state(FileState.PENDING) diff --git a/src/HABApp/core/files/file/file_state.py b/src/HABApp/core/files/file/file_state.py index eb3ec544..5d725a9a 100644 --- a/src/HABApp/core/files/file/file_state.py +++ b/src/HABApp/core/files/file/file_state.py @@ -17,5 +17,5 @@ class FileState(Enum): PENDING = auto() REMOVED = auto() - def __str__(self): + def __str__(self) -> str: return str(self.name) diff --git a/src/HABApp/core/files/file/file_types.py b/src/HABApp/core/files/file/file_types.py index 011a4c18..bf206a2c 100644 --- a/src/HABApp/core/files/file/file_types.py +++ b/src/HABApp/core/files/file/file_types.py @@ -16,7 +16,7 @@ log = logging.getLogger('HABApp.files') -def register_file_type(prefix: str, cls: type[HABAppFile]): +def register_file_type(prefix: str, cls: type[HABAppFile]) -> None: assert prefix not in FILE_TYPES assert cls.LOGGER diff --git a/src/HABApp/core/files/file/properties.py b/src/HABApp/core/files/file/properties.py index ef9c2ee0..4ace7ccb 100644 --- a/src/HABApp/core/files/file/properties.py +++ b/src/HABApp/core/files/file/properties.py @@ -1,5 +1,4 @@ import re -from typing import List from pydantic import BaseModel, ConfigDict, Field @@ -7,8 +6,8 @@ class FileProperties(BaseModel): - depends_on: List[str] = Field(alias='depends on', default_factory=list) - reloads_on: List[str] = Field(alias='reloads on', default_factory=list) + depends_on: list[str] = Field(alias='depends on', default_factory=list) + reloads_on: list[str] = Field(alias='reloads on', default_factory=list) model_config = ConfigDict(extra='forbid', populate_by_name=True) diff --git a/src/HABApp/core/files/folders/folders.py b/src/HABApp/core/files/folders/folders.py index c00c755e..606be30e 100644 --- a/src/HABApp/core/files/folders/folders.py +++ b/src/HABApp/core/files/folders/folders.py @@ -1,27 +1,25 @@ from pathlib import Path -from typing import Dict, List, Type import HABApp from HABApp.core.const.topics import TOPIC_FILES as T_FILES from HABApp.core.events.habapp_events import RequestFileLoadEvent, RequestFileUnloadEvent +from HABApp.core.files.watcher import AggregatingAsyncEventHandler from HABApp.core.internals import uses_post_event -from ..watcher import AggregatingAsyncEventHandler - -FOLDERS: Dict[str, 'ConfiguredFolder'] = {} +FOLDERS: dict[str, 'ConfiguredFolder'] = {} post_event = uses_post_event() -async def _generate_file_events(files: List[Path]): +async def _generate_file_events(files: list[Path]) -> None: for file in files: name = get_name(file) post_event(T_FILES, RequestFileLoadEvent(name) if file.is_file() else RequestFileUnloadEvent(name)) class ConfiguredFolder: - def __init__(self, prefix: str, folder: Path, priority: int): + def __init__(self, prefix: str, folder: Path, priority: int) -> None: self.prefix = prefix self.folder = folder self.priority: int = priority # priority determines the order how the files will be loaded @@ -32,11 +30,11 @@ def add_watch(self, file_ending: str, watch_subfolders: bool = True) -> Aggregat HABApp.core.files.watcher.add_folder_watch(handler) return handler - def add_file_type(self, cls: Type['HABApp.core.files.file.HABAppFile']): + def add_file_type(self, cls: type['HABApp.core.files.file.HABAppFile']) -> None: HABApp.core.files.file.register_file_type(self.prefix, cls) -def get_prefixes() -> List[str]: +def get_prefixes() -> list[str]: return list(map(lambda x: x.prefix, sorted(FOLDERS.values(), key=lambda x: x.priority, reverse=True))) diff --git a/src/HABApp/core/files/manager/files.py b/src/HABApp/core/files/manager/files.py index 62900b21..6e5a52ce 100644 --- a/src/HABApp/core/files/manager/files.py +++ b/src/HABApp/core/files/manager/files.py @@ -1,13 +1,13 @@ -from typing import TYPE_CHECKING, Dict +from typing import TYPE_CHECKING if TYPE_CHECKING: import HABApp -FILES: Dict[str, 'HABApp.core.files.file.HABAppFile'] = {} +FILES: dict[str, 'HABApp.core.files.file.HABAppFile'] = {} -def file_state_changed(file: 'HABApp.core.files.file.HABAppFile'): +def file_state_changed(file: 'HABApp.core.files.file.HABAppFile') -> None: for f in FILES.values(): if f is not file: f.file_changed(file) diff --git a/src/HABApp/core/files/manager/listen_events.py b/src/HABApp/core/files/manager/listen_events.py index e5f063e7..4c99a5c4 100644 --- a/src/HABApp/core/files/manager/listen_events.py +++ b/src/HABApp/core/files/manager/listen_events.py @@ -1,5 +1,4 @@ import logging -from typing import Union import HABApp from HABApp.core.const.topics import TOPIC_FILES as T_FILES @@ -12,12 +11,12 @@ event_bus = uses_event_bus() -async def _process_event(event: Union[RequestFileUnloadEvent, RequestFileLoadEvent]): +async def _process_event(event: RequestFileUnloadEvent | RequestFileLoadEvent) -> None: name = event.name await HABApp.core.files.manager.process_file(name, HABApp.core.files.folders.get_path(name)) -async def setup_file_manager(): +async def setup_file_manager() -> None: # Setup events so we can process load/unload event_bus.add_listener( EventBusListener( diff --git a/src/HABApp/core/files/manager/worker.py b/src/HABApp/core/files/manager/worker.py index 54fa1a3d..ee5398f7 100644 --- a/src/HABApp/core/files/manager/worker.py +++ b/src/HABApp/core/files/manager/worker.py @@ -2,7 +2,6 @@ import time from asyncio import Future, create_task, sleep from pathlib import Path -from typing import Optional import HABApp from HABApp.core.files.file import FileState @@ -14,7 +13,7 @@ log = logging.getLogger('HABApp.files') -TASK: Optional[Future] = None +TASK: Future | None = None TASK_SLEEP: float = 0.3 TASK_DURATION: float = 15 @@ -38,7 +37,7 @@ async def process_file(name: str, file: Path): TASK = create_task(_process()) -async def _process(): +async def _process() -> None: global TASK prefixes = get_prefixes() diff --git a/src/HABApp/core/files/setup.py b/src/HABApp/core/files/setup.py index 01a761a0..d361a5df 100644 --- a/src/HABApp/core/files/setup.py +++ b/src/HABApp/core/files/setup.py @@ -1,5 +1,5 @@ from .manager import setup_file_manager -async def setup(): +async def setup() -> None: await setup_file_manager() diff --git a/src/HABApp/core/files/watcher/base_watcher.py b/src/HABApp/core/files/watcher/base_watcher.py index c4da3be8..bc449b4f 100644 --- a/src/HABApp/core/files/watcher/base_watcher.py +++ b/src/HABApp/core/files/watcher/base_watcher.py @@ -2,6 +2,7 @@ from pathlib import Path from watchdog.events import EVENT_TYPE_CLOSED as WD_EVENT_TYPE_CLOSED +from watchdog.events import EVENT_TYPE_CLOSED_NO_WRITE as WD_EVENT_TYPE_CLOSED_NO_WRITE from watchdog.events import EVENT_TYPE_OPENED as WD_EVENT_TYPE_OPENED from watchdog.events import FileSystemEvent @@ -16,18 +17,18 @@ def notify(self, path: str) -> bool: class FileEndingFilter(EventFilterBase): - def __init__(self, ending: str): + def __init__(self, ending: str) -> None: self.ending: str = ending def notify(self, path: str) -> bool: return path.endswith(self.ending) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} ending: {self.ending}>' class FileSystemEventHandler: - def __init__(self, folder: Path, filter: EventFilterBase, watch_subfolders: bool = False): + def __init__(self, folder: Path, filter: EventFilterBase, watch_subfolders: bool = False) -> None: assert isinstance(folder, Path), type(folder) assert watch_subfolders is True or watch_subfolders is False @@ -44,7 +45,7 @@ def dispatch(self, event: FileSystemEvent): return None # we don't process open and close events - if (e_type := event.event_type) == WD_EVENT_TYPE_OPENED or e_type == WD_EVENT_TYPE_CLOSED: + if event.event_type in (WD_EVENT_TYPE_OPENED, WD_EVENT_TYPE_CLOSED, WD_EVENT_TYPE_CLOSED_NO_WRITE): return None src = event.src_path diff --git a/src/HABApp/core/files/watcher/file_watcher.py b/src/HABApp/core/files/watcher/file_watcher.py index 63e5673f..d43fa4c2 100644 --- a/src/HABApp/core/files/watcher/file_watcher.py +++ b/src/HABApp/core/files/watcher/file_watcher.py @@ -1,7 +1,8 @@ from asyncio import run_coroutine_threadsafe, sleep +from collections.abc import Awaitable, Callable from pathlib import Path from time import monotonic -from typing import Any, Awaitable, Callable, List, Set +from typing import Any import HABApp from HABApp.core.asyncio import AsyncContext @@ -14,17 +15,17 @@ class AggregatingAsyncEventHandler(FileSystemEventHandler): - def __init__(self, folder: Path, func: Callable[[List[Path]], Awaitable[Any]], filter: EventFilterBase, - watch_subfolders: bool = False): + def __init__(self, folder: Path, func: Callable[[list[Path]], Awaitable[Any]], filter: EventFilterBase, + watch_subfolders: bool = False) -> None: super().__init__(folder, filter, watch_subfolders=watch_subfolders) self.func = func - self._files: Set[Path] = set() + self._files: set[Path] = set() self.last_event: float = 0 @ignore_exception - def file_changed(self, dst: str): + def file_changed(self, dst: str) -> None: # Map from thread to async run_coroutine_threadsafe(self._event_waiter(Path(dst)), loop=HABApp.core.const.loop) @@ -48,7 +49,7 @@ async def _event_waiter(self, dst: Path): with AsyncContext('FileWatcherEvent'): await self.func(HABApp.core.lib.sort_files(files)) - async def trigger_all(self): + async def trigger_all(self) -> None: files = HABApp.core.lib.list_files(self.folder, self.filter, self.watch_subfolders) with AsyncContext('FileWatcherAll'): await self.func(files) diff --git a/src/HABApp/core/files/watcher/folder_watcher.py b/src/HABApp/core/files/watcher/folder_watcher.py index 17b14c64..8df80547 100644 --- a/src/HABApp/core/files/watcher/folder_watcher.py +++ b/src/HABApp/core/files/watcher/folder_watcher.py @@ -1,11 +1,12 @@ import logging from pathlib import Path from threading import Lock -from typing import Dict, Optional from watchdog.observers import Observer from watchdog.observers.api import ObservedWatch +from HABApp.core import shutdown + from .base_watcher import FileSystemEventHandler @@ -13,8 +14,8 @@ LOCK = Lock() -OBSERVER: Optional[Observer] = None -WATCHES: Dict[str, ObservedWatch] = {} +OBSERVER: Observer | None = None +WATCHES: dict[str, ObservedWatch] = {} def start(): @@ -27,13 +28,12 @@ def start(): OBSERVER.start() # register for proper shutdown - from HABApp.runtime import shutdown - shutdown.register_func(OBSERVER.stop, msg='Stopping folder observer') - shutdown.register_func(OBSERVER.join, last=True, msg='Joining folder observer threads') + shutdown.register(OBSERVER.stop, msg='Stopping folder observer') + shutdown.register(OBSERVER.join, last=True, msg='Joining folder observer threads') return None -def add_folder_watch(handler: FileSystemEventHandler): +def add_folder_watch(handler: FileSystemEventHandler) -> None: assert OBSERVER is not None assert isinstance(handler, FileSystemEventHandler), type(handler) assert isinstance(handler.folder, Path) and handler.folder.is_dir() @@ -49,7 +49,7 @@ def add_folder_watch(handler: FileSystemEventHandler): WATCHES[_folder] = OBSERVER.schedule(handler, _folder, recursive=handler.watch_subfolders) -def remove_folder_watch(folder: Path): +def remove_folder_watch(folder: Path) -> None: assert OBSERVER is not None assert isinstance(folder, Path) diff --git a/src/HABApp/core/internals/__init__.py b/src/HABApp/core/internals/__init__.py index fad6ef24..80f6bd9f 100644 --- a/src/HABApp/core/internals/__init__.py +++ b/src/HABApp/core/internals/__init__.py @@ -1,14 +1,15 @@ -from .proxy import uses_get_item, uses_item_registry, uses_post_event, uses_event_bus, setup_internals -from .context import ContextProvidingObj, Context, ContextBoundObj, get_current_context, Context, AutoContextBoundObj +from .context import AutoContextBoundObj, Context, ContextBoundObj, ContextProvidingObj, get_current_context +from .proxy import setup_internals, uses_event_bus, uses_get_item, uses_item_registry, uses_post_event + # isort: split -from .event_filter import EventFilterBase, HINT_EVENT_FILTER_OBJ from .event_bus import EventBus +from .event_filter import EventFilterBase from .item_registry import ItemRegistry, ItemRegistryItem + # isort: split -from .event_bus_listener import HINT_EVENT_BUS_LISTENER, EventBusListener, ContextBoundEventBusListener -from .event_filter import EventFilterBase, HINT_EVENT_FILTER_OBJ -from .wrapped_function import TYPE_WRAPPED_FUNC_OBJ, wrap_func +from .event_bus_listener import ContextBoundEventBusListener, EventBusListener +from .wrapped_function import WrappedFunctionBase, wrap_func diff --git a/src/HABApp/core/internals/context/context.py b/src/HABApp/core/internals/context/context.py index 896eefab..fea66cd2 100644 --- a/src/HABApp/core/internals/context/context.py +++ b/src/HABApp/core/internals/context/context.py @@ -1,10 +1,11 @@ -from typing import Callable, Optional, Set, TypeVar +from collections.abc import Callable +from typing import Any, Optional, TypeVar from HABApp.core.errors import ContextBoundObjectIsAlreadyLinkedError, ContextBoundObjectIsAlreadyUnlinkedError class ContextBoundObj: - def __init__(self, parent_ctx: Optional['Context'], **kwargs): + def __init__(self, parent_ctx: Optional['Context'], **kwargs: Any) -> None: super().__init__(**kwargs) self._parent_ctx = parent_ctx if parent_ctx is not None: @@ -30,14 +31,14 @@ def _ctx_unlink(self): class Context: - def __init__(self): - self.objs: Set[ContextBoundObj] = set() + def __init__(self) -> None: + self.objs: set[ContextBoundObj] = set() - def add_obj(self, obj: ContextBoundObj): + def add_obj(self, obj: ContextBoundObj) -> None: assert isinstance(obj, ContextBoundObj) self.objs.add(obj) - def remove_obj(self, obj: ContextBoundObj): + def remove_obj(self, obj: ContextBoundObj) -> None: assert isinstance(obj, ContextBoundObj) self.objs.remove(obj) @@ -47,11 +48,11 @@ def link(self, obj: HINT_CONTEXT_BOUND_OBJ) -> HINT_CONTEXT_BOUND_OBJ: obj._ctx_link(self) return obj - def get_callback_name(self, callback: Callable) -> Optional[str]: + def get_callback_name(self, callback: Callable) -> str | None: raise NotImplementedError() class ContextProvidingObj: - def __init__(self, context: Optional[Context] = None, **kwargs): + def __init__(self, context: Context | None = None, **kwargs: Any) -> None: super().__init__(**kwargs) self._habapp_ctx: Context = context diff --git a/src/HABApp/core/internals/context/get_context.py b/src/HABApp/core/internals/context/get_context.py index 5489b07a..c1389837 100644 --- a/src/HABApp/core/internals/context/get_context.py +++ b/src/HABApp/core/internals/context/get_context.py @@ -1,26 +1,27 @@ # noinspection PyProtectedMember from sys import _getframe as sys_get_frame -from types import FrameType -from typing import TYPE_CHECKING, Optional, Union +from typing import TYPE_CHECKING, Any, Optional from HABApp.core.errors import ContextNotFoundError, ContextNotSetError from HABApp.core.internals.context import Context, ContextBoundObj, ContextProvidingObj if TYPE_CHECKING: + from types import FrameType + import HABApp # noinspection PyProtectedMember -def get_current_context(obj: Optional[ContextProvidingObj] = None) -> 'HABApp.rule_ctx.HABAppRuleContext': +def get_current_context(obj: ContextProvidingObj | None = None) -> 'HABApp.rule_ctx.HABAppRuleContext': if obj is not None: return obj._habapp_ctx - frame: Optional[FrameType] = sys_get_frame(1) + frame: FrameType | None = sys_get_frame(1) while frame is not None: - ctx_obj: Union[None, object, ContextProvidingObj] = frame.f_locals.get('self') + ctx_obj: None | object | ContextProvidingObj = frame.f_locals.get('self') if ctx_obj is not None and isinstance(ctx_obj, ContextProvidingObj): ctx = ctx_obj._habapp_ctx if ctx is None: @@ -33,7 +34,7 @@ def get_current_context(obj: Optional[ContextProvidingObj] = None) -> 'HABApp.ru class AutoContextBoundObj(ContextBoundObj): - def __init__(self, parent_ctx: Optional['Context'] = None, **kwargs): + def __init__(self, parent_ctx: Optional['Context'] = None, **kwargs: Any) -> None: if parent_ctx is None: parent_ctx = get_current_context() super().__init__(parent_ctx=parent_ctx, **kwargs) diff --git a/src/HABApp/core/internals/event_bus/base_listener.py b/src/HABApp/core/internals/event_bus/base_listener.py index 737d16e5..3371ee45 100644 --- a/src/HABApp/core/internals/event_bus/base_listener.py +++ b/src/HABApp/core/internals/event_bus/base_listener.py @@ -1,10 +1,16 @@ +from typing import Any, Final + + class EventBusBaseListener: - def __init__(self, topic: str, **kwargs): + def __init__(self, topic: str, **kwargs: Any) -> None: super().__init__(**kwargs) - assert isinstance(topic, str) - self.topic: str = topic + if not isinstance(topic, str): + raise TypeError() + if not topic: + raise ValueError() + self.topic: Final = topic - def notify_listeners(self, event): + def notify_listeners(self, event: Any) -> None: raise NotImplementedError() def describe(self) -> str: diff --git a/src/HABApp/core/internals/event_bus/event_bus.py b/src/HABApp/core/internals/event_bus/event_bus.py index 89821db8..a9513b7a 100644 --- a/src/HABApp/core/internals/event_bus/event_bus.py +++ b/src/HABApp/core/internals/event_bus/event_bus.py @@ -1,6 +1,6 @@ import logging import threading -from typing import Any, Dict, List, TypeVar +from typing import Any from HABApp.core.const.log import TOPIC_EVENTS from HABApp.core.events import ComplexEventValue, ValueChangeEvent @@ -11,15 +11,15 @@ event_log = logging.getLogger(TOPIC_EVENTS) habapp_log = logging.getLogger('HABApp') -_TYPE_LISTENER = TypeVar('_TYPE_LISTENER', bound=EventBusBaseListener) - class EventBus: - def __init__(self): + __slots__ = ('_lock', '_listeners') + + def __init__(self) -> None: self._lock = threading.Lock() - self._listeners: Dict[str, List[EventBusBaseListener]] = {} + self._listeners: dict[str, tuple[EventBusBaseListener, ...]] = {} - def post_event(self, topic: str, event: Any): + def post_event(self, topic: str, event: Any) -> None: assert isinstance(topic, str), type(topic) if not isinstance(event, str): @@ -42,18 +42,21 @@ def post_event(self, topic: str, event: Any): pass # Notify all listeners - listeners = self._listeners.get(topic, None) - if listeners is not None: + if (listeners := self._listeners.get(topic)) is not None: for listener in listeners: listener.notify_listeners(event) return None - def add_listener(self, listener: _TYPE_LISTENER): - assert isinstance(listener, EventBusBaseListener) - assert isinstance(listener.topic, str) and listener.topic + def add_listener(self, listener: EventBusBaseListener) -> None: + if not isinstance(listener, EventBusBaseListener): + raise TypeError() + if not isinstance(topic := listener.topic, str): + raise TypeError() + if not topic: + raise ValueError() with self._lock: - item_listeners = self._listeners.setdefault(listener.topic, []) + item_listeners = self._listeners.get(topic, ()) # don't add the same listener twice if listener in item_listeners: @@ -61,16 +64,20 @@ def add_listener(self, listener: _TYPE_LISTENER): return None # add listener - item_listeners.append(listener) + self._listeners[topic] = item_listeners + (listener,) habapp_log.debug(f'Added event listener for {listener.describe()}') return None - def remove_listener(self, listener: _TYPE_LISTENER): - assert isinstance(listener, EventBusBaseListener) - assert isinstance(listener.topic, str) and listener.topic + def remove_listener(self, listener: EventBusBaseListener) -> None: + if not isinstance(listener, EventBusBaseListener): + raise TypeError() + if not isinstance(topic := listener.topic, str): + raise TypeError() + if not topic: + raise ValueError() with self._lock: - item_listeners = self._listeners.get(listener.topic, []) + item_listeners = self._listeners.get(topic, ()) # print warning if we try to remove it twice if listener not in item_listeners: @@ -78,10 +85,10 @@ def remove_listener(self, listener: _TYPE_LISTENER): return None # remove listener - item_listeners.remove(listener) + self._listeners[topic] = tuple(o for o in item_listeners if o is not listener) habapp_log.debug(f'Removed event listener for {listener.describe()}') return None - def remove_all_listeners(self): + def remove_all_listeners(self) -> None: with self._lock: self._listeners.clear() diff --git a/src/HABApp/core/internals/event_bus_listener.py b/src/HABApp/core/internals/event_bus_listener.py index aea04f7b..e0f682dd 100644 --- a/src/HABApp/core/internals/event_bus_listener.py +++ b/src/HABApp/core/internals/event_bus_listener.py @@ -1,56 +1,43 @@ -from typing import Optional, TypeVar +from typing import Any -from HABApp.core.internals import HINT_EVENT_FILTER_OBJ, AutoContextBoundObj, Context, uses_event_bus +from typing_extensions import override + +from HABApp.core.internals import AutoContextBoundObj, EventFilterBase, uses_event_bus from HABApp.core.internals.event_bus import EventBusBaseListener -from HABApp.core.internals.wrapped_function import TYPE_WRAPPED_FUNC_OBJ, WrappedFunctionBase +from HABApp.core.internals.wrapped_function import WrappedFunctionBase event_bus = uses_event_bus() class EventBusListener(EventBusBaseListener): - def __init__(self, topic: str, callback: TYPE_WRAPPED_FUNC_OBJ, event_filter: HINT_EVENT_FILTER_OBJ, **kwargs): + def __init__(self, topic: str, callback: WrappedFunctionBase, event_filter: EventFilterBase, **kwargs: Any) -> None: super().__init__(topic, **kwargs) assert isinstance(callback, WrappedFunctionBase) - self.func: TYPE_WRAPPED_FUNC_OBJ = callback - self.filter: HINT_EVENT_FILTER_OBJ = event_filter + self.func: WrappedFunctionBase = callback + self.filter: EventFilterBase = event_filter - def notify_listeners(self, event): + def notify_listeners(self, event: Any) -> None: if self.filter.trigger(event): self.func.run(event) def describe(self) -> str: return f'"{self.topic}" (filter={self.filter.describe()})' - def cancel(self): + def cancel(self) -> None: """Stop listening on the event bus""" event_bus.remove_listener(self) -HINT_EVENT_BUS_LISTENER = TypeVar('HINT_EVENT_BUS_LISTENER', bound=EventBusListener) - - class ContextBoundEventBusListener(EventBusListener, AutoContextBoundObj): - def __init__(self, topic: str, callback: TYPE_WRAPPED_FUNC_OBJ, event_filter: HINT_EVENT_FILTER_OBJ, - parent_ctx: Optional[Context] = None): - super().__init__(topic=topic, callback=callback, event_filter=event_filter, parent_ctx=parent_ctx) - - assert isinstance(callback, WrappedFunctionBase) - self.func: TYPE_WRAPPED_FUNC_OBJ = callback - self.filter: HINT_EVENT_FILTER_OBJ = event_filter - - def notify_listeners(self, event): - if self.filter.trigger(event): - self.func.run(event) - - def describe(self) -> str: - return f'"{self.topic}" (filter={self.filter.describe()})' + @override def _ctx_unlink(self): event_bus.remove_listener(self) return super()._ctx_unlink() - def cancel(self): + @override + def cancel(self) -> None: """Stop listening on the event bus""" self._ctx_unlink() diff --git a/src/HABApp/core/internals/event_filter.py b/src/HABApp/core/internals/event_filter.py index 3c7bf152..10ac817c 100644 --- a/src/HABApp/core/internals/event_filter.py +++ b/src/HABApp/core/internals/event_filter.py @@ -1,4 +1,4 @@ -from typing import Any, TypeVar +from typing import Any class EventFilterBase: @@ -8,9 +8,5 @@ def trigger(self, event: Any) -> bool: def describe(self) -> str: raise NotImplementedError() - def __repr__(self): + def __repr__(self) -> str: return f'<{self.describe()} at 0x{id(self):X}>' - - -# Hints for functions that use an item class as an input parameter -HINT_EVENT_FILTER_OBJ = TypeVar('HINT_EVENT_FILTER_OBJ', bound=EventFilterBase) diff --git a/src/HABApp/core/internals/item_registry/item_registry.py b/src/HABApp/core/internals/item_registry/item_registry.py index 9fa72f54..48c90965 100644 --- a/src/HABApp/core/internals/item_registry/item_registry.py +++ b/src/HABApp/core/internals/item_registry/item_registry.py @@ -2,21 +2,22 @@ import logging import threading -from typing import TypeVar +from typing import Final, TypeVar from HABApp.core.errors import ItemAlreadyExistsError, ItemNotFoundException from HABApp.core.internals.item_registry import ItemRegistryItem -_HINT_ITEM_OBJ = TypeVar('_HINT_ITEM_OBJ', bound=ItemRegistryItem) +ITEM_TYPE = TypeVar('ITEM_TYPE', bound=ItemRegistryItem) log = logging.getLogger('HABApp.Items') +# noinspection PyProtectedMember class ItemRegistry: - def __init__(self): + def __init__(self) -> None: self._lock = threading.Lock() - self._items: dict[str, ItemRegistryItem] = {} + self._items: Final[dict[str, ItemRegistryItem]] = {} def item_exists(self, name: str | ItemRegistryItem) -> bool: if not isinstance(name, str): @@ -35,8 +36,11 @@ def get_items(self) -> tuple[ItemRegistryItem, ...]: def get_item_names(self) -> tuple[str, ...]: return tuple(self._items.keys()) - def add_item(self, item: _HINT_ITEM_OBJ) -> _HINT_ITEM_OBJ: - assert isinstance(item, ItemRegistryItem) + def add_item(self, item: ITEM_TYPE) -> ITEM_TYPE: + if not isinstance(item, ItemRegistryItem): + msg = f'Item must be of type {ItemRegistryItem.__name__} not {type(item)}' + raise TypeError(msg) + name = item.name with self._lock: @@ -55,7 +59,7 @@ def add_item(self, item: _HINT_ITEM_OBJ) -> _HINT_ITEM_OBJ: item._on_item_added() return item - def pop_item(self, name: str | _HINT_ITEM_OBJ) -> _HINT_ITEM_OBJ: + def pop_item(self, name: str | ITEM_TYPE) -> ITEM_TYPE: if not isinstance(name, str): name = name.name diff --git a/src/HABApp/core/internals/item_registry/item_registry_item.py b/src/HABApp/core/internals/item_registry/item_registry_item.py index 4dfd8948..6b34fc90 100644 --- a/src/HABApp/core/internals/item_registry/item_registry_item.py +++ b/src/HABApp/core/internals/item_registry/item_registry_item.py @@ -2,9 +2,11 @@ class ItemRegistryItem: """ItemRegistryItem, all items that will be stored in the Item Registry must inherit from this """ - def __init__(self, name: str, **kwargs): + def __init__(self, name: str, **kwargs) -> None: super().__init__(**kwargs) - assert isinstance(name, str), type(name) + if not isinstance(name, str): + msg = f'Name must be a string not {type(name)}' + raise TypeError(msg) self._name: str = name @property @@ -14,12 +16,12 @@ def name(self) -> str: """ return self._name - def _on_item_added(self): + def _on_item_added(self) -> None: """This function gets automatically called when the item was added to the item registry """ raise NotImplementedError() - def _on_item_removed(self): + def _on_item_removed(self) -> None: """This function gets automatically called when the item was removed from the item registry """ raise NotImplementedError() diff --git a/src/HABApp/core/internals/proxy/proxies.py b/src/HABApp/core/internals/proxy/proxies.py index e1d1113c..c49255be 100644 --- a/src/HABApp/core/internals/proxy/proxies.py +++ b/src/HABApp/core/internals/proxy/proxies.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Any, Callable +from collections.abc import Callable +from typing import TYPE_CHECKING, Any from HABApp.core.internals.proxy.proxy_obj import create_proxy, replace_proxies diff --git a/src/HABApp/core/internals/proxy/proxy_obj.py b/src/HABApp/core/internals/proxy/proxy_obj.py index 59fa7a0a..218c9d8a 100644 --- a/src/HABApp/core/internals/proxy/proxy_obj.py +++ b/src/HABApp/core/internals/proxy/proxy_obj.py @@ -1,11 +1,12 @@ # noinspection PyProtectedMember +from collections.abc import Callable from sys import _getframe as sys_get_frame -from typing import Callable, Dict, Final, List, Optional +from typing import Final from HABApp.core.errors import ProxyObjHasNotBeenReplacedError -PROXIES: List['StartUpProxyObj'] = [] +PROXIES: list['StartUpProxyObj'] = [] class ProxyObjBase: @@ -19,12 +20,12 @@ def __getattr__(self, item): def __call__(self, *args, **kwargs): raise ProxyObjHasNotBeenReplacedError(self) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {self.to_replace_name}>' class ConstProxyObj(ProxyObjBase): - def __init__(self, name: str): + def __init__(self, name: str) -> None: self.name: Final = name @property @@ -33,9 +34,9 @@ def to_replace_name(self) -> str: class StartUpProxyObj(ProxyObjBase): - def __init__(self, to_replace: Callable, globals: dict): - self.to_replace: Optional[Callable] = to_replace - self.globals: Optional[dict] = globals + def __init__(self, to_replace: Callable, globals: dict) -> None: + self.to_replace: Callable | None = to_replace + self.globals: dict | None = globals PROXIES.append(self) @@ -43,7 +44,7 @@ def __init__(self, to_replace: Callable, globals: dict): def to_replace_name(self) -> str: return str(getattr(self.to_replace, '__name__', self.to_replace)) - def replace(self, replacements: Dict[object, object], final: bool): + def replace(self, replacements: dict[object, object], final: bool): assert self.globals is not None replacement = replacements[self.to_replace] @@ -67,19 +68,19 @@ def create_proxy(to_replace: Callable) -> StartUpProxyObj: class RestoreableObj: - def __init__(self, key: str, globals: dict, proxy: 'StartUpProxyObj'): + def __init__(self, key: str, globals: dict, proxy: 'StartUpProxyObj') -> None: self.key = key self.globals = globals self.proxy = proxy - def restore(self): + def restore(self) -> None: self.globals[self.key] = self.proxy self.globals = None self.key = None self.proxy = None -def replace_proxies(replacements: Dict[object, object], final: bool) -> List[RestoreableObj]: +def replace_proxies(replacements: dict[object, object], final: bool) -> list[RestoreableObj]: restore_objs = [] for proxy in PROXIES: restore = proxy.replace(replacements, final) diff --git a/src/HABApp/core/internals/wrapped_function/__init__.py b/src/HABApp/core/internals/wrapped_function/__init__.py index 49133573..14690eab 100644 --- a/src/HABApp/core/internals/wrapped_function/__init__.py +++ b/src/HABApp/core/internals/wrapped_function/__init__.py @@ -1,5 +1,5 @@ -from HABApp.core.internals.wrapped_function.base import TYPE_WRAPPED_FUNC_OBJ, WrappedFunctionBase +from HABApp.core.internals.wrapped_function.base import WrappedFunctionBase # isort: split -from HABApp.core.internals.wrapped_function.wrapper import wrap_func, run_function +from HABApp.core.internals.wrapped_function.wrapper import wrap_func diff --git a/src/HABApp/core/internals/wrapped_function/base.py b/src/HABApp/core/internals/wrapped_function/base.py index 1548e822..3dc424d1 100644 --- a/src/HABApp/core/internals/wrapped_function/base.py +++ b/src/HABApp/core/internals/wrapped_function/base.py @@ -1,21 +1,31 @@ +from __future__ import annotations + import logging -from typing import Callable, Optional, TypeVar +from typing import TYPE_CHECKING, Any, Final, Generic, ParamSpec, TypeVar -from HABApp.core.const.topics import TOPIC_ERRORS as TOPIC_ERRORS +from HABApp.core.const.topics import TOPIC_ERRORS from HABApp.core.events.habapp_events import HABAppException from HABApp.core.internals import Context, ContextProvidingObj, uses_event_bus from HABApp.core.lib import format_exception +if TYPE_CHECKING: + from collections.abc import Callable + + default_logger = logging.getLogger('HABApp.Worker') event_bus = uses_event_bus() -class WrappedFunctionBase(ContextProvidingObj): +P = ParamSpec('P') +R = TypeVar('R') + + +class WrappedFunctionBase(ContextProvidingObj, Generic[P, R]): - def __init__(self, func: Callable, name: Optional[str] = None, logger: Optional[logging.Logger] = None, - context: Optional[Context] = None): + def __init__(self, func: Callable, name: str | None = None, logger: logging.Logger | None = None, + context: Context | None = None) -> None: # Allow setting of the rule context super().__init__(context) @@ -26,22 +36,23 @@ def __init__(self, func: Callable, name: Optional[str] = None, logger: Optional[ name = self._habapp_ctx.get_callback_name(func) if name is None: name = func.__name__ - self.name: str = name + self.name: Final = name # Allow custom logger - self.log = default_logger - if logger: - self.log = logger + self.log: Final = default_logger if logger is None else logger - def run(self, *args, **kwargs): + def run(self, *args: P.args, **kwargs: P.kwargs) -> None: raise NotImplementedError() - def process_exception(self, e: Exception, *args, **kwargs): + async def async_run(self, *args: P.args, **kwargs: P.kwargs) -> R | None: + raise NotImplementedError() + + def process_exception(self, e: Exception, *args: Any, **kwargs: Any) -> None: lines = format_exception(e) # Log Exception - self.log.error(f'Error in {self.name}: {e}') + self.log.error(f'Error in {self.name:s}: {e}') for line in lines: self.log.error(line) @@ -51,6 +62,3 @@ def process_exception(self, e: Exception, *args, **kwargs): event_bus.post_event( TOPIC_ERRORS, HABAppException(func_name=self.name, exception=e, traceback='\n'.join(lines)) ) - - -TYPE_WRAPPED_FUNC_OBJ = TypeVar('TYPE_WRAPPED_FUNC_OBJ', bound=WrappedFunctionBase) diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_async.py b/src/HABApp/core/internals/wrapped_function/wrapped_async.py index 516ee393..589e41ff 100644 --- a/src/HABApp/core/internals/wrapped_function/wrapped_async.py +++ b/src/HABApp/core/internals/wrapped_function/wrapped_async.py @@ -1,35 +1,38 @@ import logging -from typing import Optional +from collections.abc import Callable, Coroutine +from typing import Any, Final + +from typing_extensions import override from HABApp.core.asyncio import async_context, create_task -from HABApp.core.const.hints import TYPE_FUNC_ASYNC from HABApp.core.internals import Context - -from .base import WrappedFunctionBase +from HABApp.core.internals.wrapped_function.base import P, R, WrappedFunctionBase -class WrappedAsyncFunction(WrappedFunctionBase): +class WrappedAsyncFunction(WrappedFunctionBase[P, R]): - def __init__(self, func: TYPE_FUNC_ASYNC, - name: Optional[str] = None, - logger: Optional[logging.Logger] = None, - context: Optional[Context] = None): + def __init__(self, coro: Callable[P, Coroutine[Any, Any, R]], + name: str | None = None, + logger: logging.Logger | None = None, + context: Context | None = None) -> None: - super().__init__(name=name, func=func, logger=logger, context=context) - assert callable(func) + super().__init__(name=name, func=coro, logger=logger, context=context) - self.func = func + self.coro: Final = coro - def run(self, *args, **kwargs): + @override + def run(self, *args: P.args, **kwargs: P.kwargs) -> None: create_task(self.async_run(*args, **kwargs), name=self.name) - async def async_run(self, *args, **kwargs): + @override + async def async_run(self, *args: P.args, **kwargs: P.kwargs) -> R | None: token = async_context.set('WrappedAsyncFunction') try: - await self.func(*args, **kwargs) + return await self.coro(*args, **kwargs) except Exception as e: self.process_exception(e, *args, **kwargs) + return None finally: async_context.reset(token) diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_sync.py b/src/HABApp/core/internals/wrapped_function/wrapped_sync.py index 452ebd29..7d15a77b 100644 --- a/src/HABApp/core/internals/wrapped_function/wrapped_sync.py +++ b/src/HABApp/core/internals/wrapped_function/wrapped_sync.py @@ -1,19 +1,22 @@ +from __future__ import annotations + import logging -from typing import Callable, Optional +from collections.abc import Callable + +from typing_extensions import override from HABApp.core.asyncio import async_context, create_task from HABApp.core.internals import Context - -from .base import WrappedFunctionBase +from HABApp.core.internals.wrapped_function.base import P, R, WrappedFunctionBase -class WrappedSyncFunction(WrappedFunctionBase): +class WrappedSyncFunction(WrappedFunctionBase[P, R]): def __init__(self, func: Callable, warn_too_long=True, - name: Optional[str] = None, - logger: Optional[logging.Logger] = None, - context: Optional[Context] = None): + name: str | None = None, + logger: logging.Logger | None = None, + context: Context | None = None) -> None: super().__init__(name=name, func=func, logger=logger, context=context) assert callable(func) @@ -21,15 +24,17 @@ def __init__(self, func: Callable, self.func = func self.warn_too_long: bool = warn_too_long - def run(self, *args, **kwargs): + @override + def run(self, *args: P.args, **kwargs: P.kwargs) -> None: create_task(self.async_run(*args, **kwargs), name=self.name) - async def async_run(self, *args, **kwargs): + @override + async def async_run(self, *args: P.args, **kwargs: P.kwargs) -> R | None: token = async_context.set('WrappedSyncFunction') try: - self.func(*args, **kwargs) + return self.func(*args, **kwargs) except Exception as e: self.process_exception(e, *args, **kwargs) finally: diff --git a/src/HABApp/core/internals/wrapped_function/wrapped_thread.py b/src/HABApp/core/internals/wrapped_function/wrapped_thread.py index c2543cb5..4e113aaa 100644 --- a/src/HABApp/core/internals/wrapped_function/wrapped_thread.py +++ b/src/HABApp/core/internals/wrapped_function/wrapped_thread.py @@ -1,27 +1,32 @@ -import io -import logging +from __future__ import annotations + from concurrent.futures import ThreadPoolExecutor -from cProfile import Profile -from pstats import SortKey, Stats from threading import Lock from time import monotonic -from typing import Any, Callable, Dict, Final, Optional, Set, Tuple +from typing import TYPE_CHECKING, Any, Final + +from typing_extensions import override from HABApp.core.const import loop from HABApp.core.internals import Context, ContextProvidingObj +from HABApp.core.internals.wrapped_function.base import P, R, WrappedFunctionBase, default_logger -from .base import WrappedFunctionBase, default_logger +if TYPE_CHECKING: + import logging + from collections.abc import Callable -POOL: Optional[ThreadPoolExecutor] = None + +POOL: ThreadPoolExecutor | None = None POOL_THREADS: int = 0 -POOL_INFO: Set['PoolFunc'] = set() -POOL_LOCK = Lock() -def create_thread_pool(count: int): +def create_thread_pool(count: int) -> None: global POOL, POOL_THREADS - assert isinstance(count, int) and count > 0 + + if not isinstance(count, int) or count <= 0: + msg = 'Thread count must be a positive integer' + raise ValueError(msg) default_logger.debug(f'Starting thread pool with {count:d} threads!') @@ -30,27 +35,26 @@ def create_thread_pool(count: int): POOL = ThreadPoolExecutor(count, 'HabAppWorker') -def stop_thread_pool(): +def stop_thread_pool() -> None: global POOL, POOL_THREADS - if POOL is not None: - POOL.shutdown() - POOL = None - POOL_THREADS = 0 - default_logger.debug('Thread pool stopped!') + if (pool := POOL) is None: + return None -async def run_in_thread_pool(func: Callable): - return await loop.run_in_executor( - POOL, func - ) + POOL_THREADS = 0 + POOL = None + pool.shutdown() + default_logger.debug('Thread pool stopped!') -HINT_FUNC_SYNC = Callable[..., Any] + +POOL_INFO: Final[set[PoolFunc]] = set() +POOL_LOCK: Final = Lock() class PoolFunc(ContextProvidingObj): - def __init__(self, parent: 'WrappedThreadFunction', func_obj: HINT_FUNC_SYNC, func_args: Tuple[Any, ...], - func_kwargs: Dict[str, Any], context: Optional[Context] = None, **kwargs): + def __init__(self, parent: WrappedThreadFunction, func_obj: Callable[..., Any], func_args: tuple[Any, ...], + func_kwargs: dict[str, Any], context: Context | None = None, **kwargs: Any) -> None: super().__init__(context=context, **kwargs) self.parent: Final = parent self.func_obj: Final = func_obj @@ -58,43 +62,41 @@ def __init__(self, parent: 'WrappedThreadFunction', func_obj: HINT_FUNC_SYNC, fu self.func_kwargs: Final = func_kwargs # timing checks - self.submitted = monotonic() - self.dur_start = 0.0 - self.dur_run = 0.0 + self.submitted: float = monotonic() + self.dur_start: float = 0.0 + self.dur_run: float = 0.0 # thread info - self.usage_high = 0 + self.usage_high: int = 0 - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} high: {self.usage_high:d}/{POOL_THREADS:d}>' - def run(self): + def run(self) -> Any: + pool_lock: Final = POOL_LOCK + pool_info: Final = POOL_INFO + parent = self.parent + try: ts_start = monotonic() self.dur_start = ts_start - self.submitted - with POOL_LOCK: - POOL_INFO.add(self) - count = len(POOL_INFO) - for info in POOL_INFO: + with pool_lock: + pool_info.add(self) + count = len(pool_info) + for info in pool_info: info.usage_high = max(count, info.usage_high) - parent = self.parent - # notify if we don't process quickly if self.dur_start > 0.05: parent.log.warning(f'Starting of {parent.name} took too long: {self.dur_start:.2f}s. ' f'Maybe there are not enough threads?') - # start profiler - pr = Profile() - pr.enable() + # Profiler does not work + # https://github.com/python/cpython/issues/124656 # Execute the function - self.func_obj(*self.func_args, **self.func_kwargs) - - # disable profiler - pr.disable() + ret = self.func_obj(*self.func_args, **self.func_kwargs) # log warning if execution takes too long self.dur_run = monotonic() - ts_start @@ -103,37 +105,38 @@ def run(self): parent.log.warning(f'{self.usage_high}/{POOL_THREADS} threads have been in use and ' f'execution of {parent.name} took too long: {self.dur_run:.2f}s') - s = io.StringIO() - ps = Stats(pr, stream=s).sort_stats(SortKey.CUMULATIVE) - ps.print_stats(0.1) # limit to output to 10% of the lines - - for line in s.getvalue().splitlines()[4:]: # skip the amount of calls and "Ordered by:" - if line: - parent.log.warning(line) - except Exception as e: self.parent.process_exception(e, *self.func_args, **self.func_kwargs) return None + else: + return ret finally: - with POOL_LOCK: - POOL_INFO.discard(self) + with pool_lock: + pool_info.discard(self) -class WrappedThreadFunction(WrappedFunctionBase): +class WrappedThreadFunction(WrappedFunctionBase[P, R]): - def __init__(self, func: HINT_FUNC_SYNC, - warn_too_long=True, - name: Optional[str] = None, - logger: Optional[logging.Logger] = None, - context: Optional[Context] = None): + def __init__(self, func: Callable[P, R], + warn_too_long: bool = True, + name: str | None = None, + logger: logging.Logger | None = None, + context: Context | None = None) -> None: super().__init__(name=name, func=func, logger=logger, context=context) - assert callable(func) - self.func = func - self.warn_too_long: bool = warn_too_long + self.func: Final = func + self.warn_too_long: Final = warn_too_long - def run(self, *args, **kwargs): + @override + def run(self, *args: P.args, **kwargs: P.kwargs) -> None: # we need to copy the context, so it's available when the function is run pool_func = PoolFunc(self, self.func, args, kwargs, context=self._habapp_ctx) POOL.submit(pool_func.run) + return None + + @override + async def async_run(self, *args: P.args, **kwargs: P.kwargs) -> R | None: + + pool_func = PoolFunc(self, self.func, args, kwargs, context=self._habapp_ctx) + return await loop.run_in_executor(POOL, pool_func.run) diff --git a/src/HABApp/core/internals/wrapped_function/wrapper.py b/src/HABApp/core/internals/wrapped_function/wrapper.py index 6a758cea..826c7e34 100644 --- a/src/HABApp/core/internals/wrapped_function/wrapper.py +++ b/src/HABApp/core/internals/wrapped_function/wrapper.py @@ -1,26 +1,26 @@ import logging from asyncio import iscoroutinefunction -from typing import Callable, Optional, Type, Union +from collections.abc import Callable, Coroutine +from typing import Any from HABApp.config import CONFIG +from HABApp.core import shutdown from HABApp.core.internals import Context -from HABApp.core.internals.wrapped_function.base import TYPE_WRAPPED_FUNC_OBJ -from HABApp.core.internals.wrapped_function.wrapped_async import TYPE_FUNC_ASYNC, WrappedAsyncFunction +from HABApp.core.internals.wrapped_function.base import P, R, WrappedFunctionBase +from HABApp.core.internals.wrapped_function.wrapped_async import WrappedAsyncFunction from HABApp.core.internals.wrapped_function.wrapped_sync import WrappedSyncFunction from HABApp.core.internals.wrapped_function.wrapped_thread import ( - HINT_FUNC_SYNC, WrappedThreadFunction, create_thread_pool, - run_in_thread_pool, stop_thread_pool, ) -def wrap_func(func: Union[HINT_FUNC_SYNC, TYPE_FUNC_ASYNC], +def wrap_func(func: Callable[P, R] | Callable[P, Coroutine[Any, Any, R]], warn_too_long=True, - name: Optional[str] = None, - logger: Optional[logging.Logger] = None, - context: Optional[Context] = None) -> TYPE_WRAPPED_FUNC_OBJ: + name: str | None = None, + logger: logging.Logger | None = None, + context: Context | None = None) -> WrappedFunctionBase[P, R]: # Check that it's actually a callable, so we fail fast and not when we try to run the function. # Some users pass the result of the function call (e.g. func()) by accident @@ -36,14 +36,14 @@ def wrap_func(func: Union[HINT_FUNC_SYNC, TYPE_FUNC_ASYNC], if iscoroutinefunction(func): return WrappedAsyncFunction(func, name=name, logger=logger, context=context) - else: - return SYNC_CLS(func, warn_too_long=warn_too_long, name=name, logger=logger, context=context) + return SYNC_CLS(func, warn_too_long=warn_too_long, name=name, logger=logger, context=context) -SYNC_CLS: Union[Type[WrappedThreadFunction], Type[WrappedSyncFunction]] +SYNC_CLS: type[WrappedThreadFunction] | type[WrappedSyncFunction] -def setup(): + +def setup() -> None: global SYNC_CLS if not THREAD_POOL.enabled: @@ -51,23 +51,16 @@ def setup(): # In case of hot reload stop_thread_pool() - else: - SYNC_CLS = WrappedThreadFunction + return None + + SYNC_CLS = WrappedThreadFunction - # create thread pool - create_thread_pool(THREAD_POOL.threads) + # create thread pool + create_thread_pool(THREAD_POOL.threads) - # this function can be called multiple times, so it's no problem if we register it more than once! - from HABApp.runtime import shutdown - shutdown.register_func(stop_thread_pool, msg='Stopping thread pool', last=True) + # this function can be called multiple times, so it's no problem if we register it more than once! + shutdown.register(stop_thread_pool, msg='Stopping thread pool', last=True) THREAD_POOL = CONFIG.habapp.thread_pool THREAD_POOL.subscribe_for_changes(setup) - - -async def run_function(func: Callable): - if not THREAD_POOL.enabled: - return func() - else: - return await run_in_thread_pool(func) diff --git a/src/HABApp/core/items/__init__.py b/src/HABApp/core/items/__init__.py index 207309ed..52186de1 100644 --- a/src/HABApp/core/items/__init__.py +++ b/src/HABApp/core/items/__init__.py @@ -1,8 +1,7 @@ -from .base_item import BaseItem, HINT_TYPE_ITEM_OBJ, HINT_ITEM_OBJ +from .base_item import BaseItem from .base_valueitem import BaseValueItem # isort split - from .item import Item -from .item_color import ColorItem from .item_aggregation import AggregationItem +from .item_color import ColorItem diff --git a/src/HABApp/core/items/base_item.py b/src/HABApp/core/items/base_item.py index 7a0f4190..c35f9878 100644 --- a/src/HABApp/core/items/base_item.py +++ b/src/HABApp/core/items/base_item.py @@ -1,20 +1,17 @@ -from typing import Optional, Type, TypeVar - -from eascheduler.const import local_tz -from pendulum import UTC, DateTime -from pendulum import now as pd_now +from eascheduler.builder.helper import HINT_POS_TIMEDELTA, get_pos_timedelta_secs +from whenever import Instant +from HABApp.core.const.hints import TYPE_EVENT_CALLBACK from HABApp.core.internals import ( - HINT_EVENT_BUS_LISTENER, - HINT_EVENT_FILTER_OBJ, + EventBusListener, + EventFilterBase, get_current_context, uses_get_item, uses_item_registry, ) from HABApp.core.internals.item_registry import ItemRegistryItem -from HABApp.core.lib.parameters import TH_POSITIVE_TIME_DIFF, get_positive_time_diff +from HABApp.core.lib import InstantView -from ..const.hints import TYPE_EVENT_CALLBACK from .base_item_times import ChangedTime, ItemNoChangeWatch, ItemNoUpdateWatch, UpdatedTime from .tmp_data import add_tmp_data as _add_tmp_data from .tmp_data import restore_tmp_data as _restore_tmp_data @@ -39,78 +36,73 @@ def get_item(cls, name: str): assert isinstance(item, cls), f'{cls} != {type(item)}' return item - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__(name) - _now = pd_now(UTC) + _now = Instant.now() self._last_change: ChangedTime = ChangedTime(self._name, _now) self._last_update: UpdatedTime = UpdatedTime(self._name, _now) @property - def last_change(self) -> DateTime: + def last_change(self) -> InstantView: """ :return: Timestamp of the last time when the item has been changed (read only) """ - return self._last_change.dt.in_timezone(local_tz).naive() + return InstantView(self._last_change.instant) @property - def last_update(self) -> DateTime: + def last_update(self) -> InstantView: """ :return: Timestamp of the last time when the item has been updated (read only) """ - return self._last_update.dt.in_timezone(local_tz).naive() + return InstantView(self._last_update.instant) - def __repr__(self): + def __repr__(self) -> str: ret = '' for k in ['name', 'last_change', 'last_update']: ret += f'{", " if ret else ""}{k}: {getattr(self, k)}' return f'<{self.__class__.__name__} {ret:s}>' - def watch_change(self, secs: TH_POSITIVE_TIME_DIFF) -> ItemNoChangeWatch: + def watch_change(self, secs: HINT_POS_TIMEDELTA) -> ItemNoChangeWatch: """Generate an event if the item does not change for a certain period of time. Has to be called from inside a rule function. :param secs: secs after which the event will occur, max 1 decimal digit for floats :return: The watch obj which can be used to cancel the watch """ - secs = get_positive_time_diff(secs, round_digits=1) + secs = round(get_pos_timedelta_secs(secs), 1) return self._last_change.add_watch(secs) - def watch_update(self, secs: TH_POSITIVE_TIME_DIFF) -> ItemNoUpdateWatch: + def watch_update(self, secs: HINT_POS_TIMEDELTA) -> ItemNoUpdateWatch: """Generate an event if the item does not receive and update for a certain period of time. Has to be called from inside a rule function. :param secs: secs after which the event will occur, max 1 decimal digit for floats :return: The watch obj which can be used to cancel the watch """ - secs = get_positive_time_diff(secs, round_digits=1) + secs = round(get_pos_timedelta_secs(secs), 1) return self._last_update.add_watch(secs) def listen_event(self, callback: TYPE_EVENT_CALLBACK, - event_filter: Optional[HINT_EVENT_FILTER_OBJ] = None) -> HINT_EVENT_BUS_LISTENER: + event_filter: EventFilterBase | None = None) -> EventBusListener: """ Register an event listener which listens to all event that the item receives :param callback: callback that accepts one parameter which will contain the event :param event_filter: Event filter. This is typically :class:`~HABApp.core.events.ValueUpdateEventFilter` or :class:`~HABApp.core.events.ValueChangeEventFilter` which will also trigger on changes/update from openhab - or mqtt. Additionally it can be an instance of :class:`~HABApp.core.events.EventFilter` which additionally + or mqtt. Additionally, it can be an instance of :class:`~HABApp.core.events.EventFilter` which additionally filters on the values of the event. It is also possible to group filters logically with, e.g. :class:`~HABApp.core.events.AndFilterGroup` and :class:`~HABApp.core.events.OrFilterGroup` """ return get_current_context().rule.listen_event(self._name, callback=callback, event_filter=event_filter) - def _on_item_added(self): + def _on_item_added(self) -> None: """This function gets automatically called when the item is added to the item registry """ _restore_tmp_data(self) - def _on_item_removed(self): + def _on_item_removed(self) -> None: """This function gets automatically called when the item is removed from the item registry """ _add_tmp_data(self) - - -# Hints for functions that use an item class as an input parameter -HINT_ITEM_OBJ = TypeVar('HINT_ITEM_OBJ', bound=BaseItem) -HINT_TYPE_ITEM_OBJ = Type[HINT_ITEM_OBJ] diff --git a/src/HABApp/core/items/base_item_times.py b/src/HABApp/core/items/base_item_times.py index 64f63ea6..100bcaba 100644 --- a/src/HABApp/core/items/base_item_times.py +++ b/src/HABApp/core/items/base_item_times.py @@ -1,11 +1,10 @@ import logging -from typing import Generic, List, Type, TypeVar, Union +from typing import Generic, TypeVar -from pendulum import DateTime +from whenever import Instant from HABApp.core.asyncio import run_func_from_async - -from .base_item_watch import BaseWatch, ItemNoChangeWatch, ItemNoUpdateWatch +from HABApp.core.items.base_item_watch import BaseWatch, ItemNoChangeWatch, ItemNoUpdateWatch log = logging.getLogger('HABApp') @@ -14,15 +13,15 @@ class ItemTimes(Generic[WATCH_OBJ]): - WATCH: Union[Type[ItemNoUpdateWatch], Type[ItemNoChangeWatch]] + WATCH: type[ItemNoUpdateWatch] | type[ItemNoChangeWatch] - def __init__(self, name: str, dt: DateTime): + def __init__(self, name: str, instant: Instant) -> None: self.name: str = name - self.dt: DateTime = dt - self.tasks: List[WATCH_OBJ] = [] + self.instant: Instant = instant + self.tasks: list[WATCH_OBJ] = [] - def set(self, dt: DateTime, events=True): - self.dt = dt + def set(self, instant: Instant, events=True): + self.instant = instant if not self.tasks: return @@ -30,7 +29,7 @@ def set(self, dt: DateTime, events=True): run_func_from_async(self.__async_schedule_events) return None - def add_watch(self, secs: Union[int, float]) -> WATCH_OBJ: + def add_watch(self, secs: int | float) -> WATCH_OBJ: # don't add the watch two times for t in self.tasks: if not t.fut.is_canceled and t.fut.secs == secs: diff --git a/src/HABApp/core/items/base_item_watch.py b/src/HABApp/core/items/base_item_watch.py index ad86e79b..859fcc50 100644 --- a/src/HABApp/core/items/base_item_watch.py +++ b/src/HABApp/core/items/base_item_watch.py @@ -1,9 +1,9 @@ +from __future__ import annotations + import logging import typing -import HABApp from HABApp.core.asyncio import run_func_from_async -from HABApp.core.const.hints import TYPE_EVENT_CALLBACK from HABApp.core.events import EventFilter, ItemNoChangeEvent, ItemNoUpdateEvent from HABApp.core.internals import ( AutoContextBoundObj, @@ -15,32 +15,37 @@ from HABApp.core.lib import PendingFuture +if typing.TYPE_CHECKING: + import HABApp + from HABApp.core.const.hints import TYPE_EVENT_CALLBACK + + log = logging.getLogger('HABApp') post_event = uses_post_event() class BaseWatch(AutoContextBoundObj): - EVENT: typing.Union[typing.Type[ItemNoUpdateEvent], typing.Type[ItemNoChangeEvent]] + EVENT: type[ItemNoUpdateEvent | ItemNoChangeEvent] - def __init__(self, name: str, secs: typing.Union[int, float]): + def __init__(self, name: str, secs: int | float) -> None: super().__init__() self.fut = PendingFuture(self._post_event, secs) self.name: str = name - async def _post_event(self): + async def _post_event(self) -> None: post_event(self.name, self.EVENT(self.name, self.fut.secs)) - def __cancel_watch(self): + def __cancel_watch(self) -> None: self.fut.cancel() log.debug(f'Canceled {self.__class__.__name__} ({self.fut.secs}s) for {self.name}') - def cancel(self): + def cancel(self) -> None: """Cancel the item watch""" self._ctx_unlink() run_func_from_async(self.__cancel_watch) - def listen_event(self, callback: TYPE_EVENT_CALLBACK) -> 'HABApp.core.base.HINT_EVENT_BUS_LISTENER': + def listen_event(self, callback: TYPE_EVENT_CALLBACK) -> HABApp.core.base.EventBusListener: """Listen to (only) the event that is emitted by this watcher""" context = get_current_context() return context.add_event_listener( diff --git a/src/HABApp/core/items/base_valueitem.py b/src/HABApp/core/items/base_valueitem.py index 1b18507d..4f1ad9e5 100644 --- a/src/HABApp/core/items/base_valueitem.py +++ b/src/HABApp/core/items/base_valueitem.py @@ -1,19 +1,18 @@ import logging -import typing from datetime import datetime from math import ceil, floor +from typing import TYPE_CHECKING, Any -from pendulum import UTC -from pendulum import now as pd_now +from whenever import Instant from HABApp.core.const import MISSING -from HABApp.core.events import ValueChangeEvent, ValueUpdateEvent +from HABApp.core.events import ValueChangeEvent, ValueCommandEvent, ValueUpdateEvent from HABApp.core.internals import uses_post_event from HABApp.core.items.base_item import BaseItem from HABApp.core.lib.funcs import compare as _compare -if typing.TYPE_CHECKING: +if TYPE_CHECKING: datetime = datetime @@ -31,12 +30,12 @@ class BaseValueItem(BaseItem): :ivar datetime last_update: Timestamp of the last time when the item has updated the value (read only) """ - def __init__(self, name: str, initial_value=None): + def __init__(self, name: str, initial_value=None) -> None: super().__init__(name) - self.value: typing.Any = initial_value + self.value: Any = initial_value - def set_value(self, new_value) -> bool: + def set_value(self, new_value: Any) -> bool: """Set a new value without creating events on the event bus :param new_value: new value of the item @@ -44,7 +43,7 @@ def set_value(self, new_value) -> bool: """ state_changed = self.value != new_value - _now = pd_now(UTC) + _now = Instant.now() if state_changed: self._last_change.set(_now) self._last_update.set(_now) @@ -52,7 +51,7 @@ def set_value(self, new_value) -> bool: self.value = new_value return state_changed - def post_value(self, new_value) -> bool: + def post_value(self, new_value: Any) -> bool: """Set a new value and post appropriate events on the HABApp event bus (``ValueUpdateEvent``, ``ValueChangeEvent``) @@ -70,6 +69,15 @@ def post_value(self, new_value) -> bool: ) return state_changed + def command_value(self, value: Any) -> None: + """Send a ``ValueCommandEvent`` for the item to the HABApp event bus. A ``ValueCommandEvent`` is typically + used to indicate that the item should change the value. + E.g. a command "ON" to a dimmer might result in a brightness value of 100%. + + :param value: the commanded value + """ + post_event(self._name, ValueCommandEvent(self._name, value)) + def post_value_if(self, new_value, *, equal=MISSING, eq=MISSING, not_equal=MISSING, ne=MISSING, lower_than=MISSING, lt=MISSING, lower_equal=MISSING, le=MISSING, greater_than=MISSING, gt=MISSING, greater_equal=MISSING, ge=MISSING, @@ -104,7 +112,7 @@ def post_value_if(self, new_value, *, equal=MISSING, eq=MISSING, not_equal=MISSI return True return False - def get_value(self, default_value=None) -> typing.Any: + def get_value(self, default_value=None) -> Any: """Return the value of the item. This is a helper function that returns a default in case the item value is None. @@ -115,7 +123,7 @@ def get_value(self, default_value=None) -> typing.Any: return default_value return self.value - def __repr__(self): + def __repr__(self) -> str: ret = '' for k in ['name', 'value', 'last_change', 'last_update']: ret += f'{", " if ret else ""}{k}: {getattr(self, k)}' @@ -123,23 +131,23 @@ def __repr__(self): # only support == and != operators by default # __ne__ delegates to __eq__ and inverts the result so this is not overloaded separately - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: return self.value == other - def __bool__(self): + def __bool__(self) -> bool: return bool(self.value) # rich comparisons - def __lt__(self, other): + def __lt__(self, other: Any) -> bool: return self.value < other - def __le__(self, other): + def __le__(self, other: Any) -> bool: return self.value <= other - def __ge__(self, other): + def __ge__(self, other: Any) -> bool: return self.value >= other - def __gt__(self, other): + def __gt__(self, other: Any) -> bool: return self.value > other # https://docs.python.org/3/reference/datamodel.html#emulating-numeric-types @@ -228,13 +236,13 @@ def __invert__(self): return self.value.__invert__() # built-in functions complex(), int() and float(). - def __complex__(self): + def __complex__(self) -> complex: return self.value.__complex__() - def __int__(self): + def __int__(self) -> int: return self.value.__int__() - def __float__(self): + def __float__(self) -> float: return self.value.__float__() # built-in function round() and math functions trunc(), floor() and ceil(). diff --git a/src/HABApp/core/items/item_aggregation.py b/src/HABApp/core/items/item_aggregation.py index 2497f996..a6e16f9f 100644 --- a/src/HABApp/core/items/item_aggregation.py +++ b/src/HABApp/core/items/item_aggregation.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import asyncio import collections import time @@ -8,7 +10,6 @@ from HABApp.core.errors import ItemNotFoundException from HABApp.core.events import EventFilter, ValueChangeEvent, ValueUpdateEvent from HABApp.core.internals import ( - HINT_EVENT_BUS_LISTENER, EventBusListener, uses_event_bus, uses_get_item, @@ -27,7 +28,7 @@ class AggregationItem(BaseValueItem): @classmethod - def get_create_item(cls, name: str): + def get_create_item(cls, name: str) -> AggregationItem: """Creates a new AggregationItem in HABApp and returns it or returns the already existing item with the given name @@ -45,19 +46,19 @@ def get_create_item(cls, name: str): assert isinstance(item, cls), f'{cls} != {type(item)}' return item - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__(name) self.__period: float = 0 self.__aggregation_func: typing.Callable[[typing.Iterable], typing.Any] = lambda x: x - self._ts: typing.Deque[float] = collections.deque() - self._vals: typing.Deque[typing.Any] = collections.deque() + self._ts: collections.deque[float] = collections.deque() + self._vals: collections.deque[typing.Any] = collections.deque() - self.__listener: typing.Optional[HINT_EVENT_BUS_LISTENER] = None + self.__listener: EventBusListener | None = None - self.__task: typing.Optional[asyncio.Future] = None + self.__task: asyncio.Future | None = None - def aggregation_func(self, func: typing.Callable[[typing.Iterable], typing.Any]) -> 'AggregationItem': + def aggregation_func(self, func: typing.Callable[[typing.Iterable], typing.Any]) -> AggregationItem: """Set the function which will be used to aggregate all values. E.g. ``min`` or ``max`` :param func: The function which takes an iterator an returns an aggregated value. @@ -66,7 +67,7 @@ def aggregation_func(self, func: typing.Callable[[typing.Iterable], typing.Any]) self.__aggregation_func = func return self - def aggregation_period(self, period: typing.Union[float, int, timedelta]) -> 'AggregationItem': + def aggregation_period(self, period: float | int | timedelta) -> AggregationItem: """Set the period in which the items will be aggregated :param period: period in seconds @@ -84,8 +85,8 @@ def aggregation_period(self, period: typing.Union[float, int, timedelta]) -> 'Ag return self - def aggregation_source(self, source: typing.Union[BaseValueItem, str], - only_changes: bool = False) -> 'AggregationItem': + def aggregation_source(self, source: BaseValueItem | str, + only_changes: bool = False) -> AggregationItem: """Set the source item which changes will be aggregated :param source: name or Item obj @@ -105,7 +106,7 @@ def aggregation_source(self, source: typing.Union[BaseValueItem, str], event_bus.add_listener(self.__listener) return self - def _on_item_removed(self): + def _on_item_removed(self) -> None: super()._on_item_removed() if self.__listener is not None: diff --git a/src/HABApp/core/items/item_color.py b/src/HABApp/core/items/item_color.py index 352dd183..866e3137 100644 --- a/src/HABApp/core/items/item_color.py +++ b/src/HABApp/core/items/item_color.py @@ -1,5 +1,4 @@ import typing -from typing import Optional from HABApp.core.errors import ItemNotFoundException from HABApp.core.internals import uses_item_registry @@ -16,7 +15,7 @@ class ColorItem(BaseValueItem): """Item for dealing with color related values""" - def __init__(self, name: str, hue=0.0, saturation=0.0, brightness=0.0): + def __init__(self, name: str, hue=0.0, saturation=0.0, brightness=0.0) -> None: super().__init__(name=name, initial_value=(hue, saturation, brightness)) self.hue: float = min(max(0.0, hue), HUE_FACTOR) @@ -43,7 +42,7 @@ def set_value(self, hue=0.0, saturation=0.0, brightness=0.0): return super().set_value(new_value=(self.hue, self.saturation, self.brightness)) - def post_value(self, hue=0.0, saturation=0.0, brightness=0.0): + def post_value(self, hue=0.0, saturation=0.0, brightness=0.0) -> None: """Set a new value and post appropriate events on the HABApp event bus (``ValueUpdateEvent``, ``ValueChangeEvent``) @@ -66,7 +65,7 @@ def get_rgb(self, max_rgb_value=255) -> typing.Tuple[int, int, int]: """ return hsb_to_rgb(self.hue, self.saturation, self.brightness, max_rgb_value=max_rgb_value) - def set_rgb(self, r, g, b, max_rgb_value=255, ndigits: Optional[int] = 2) -> 'ColorItem': + def set_rgb(self, r, g, b, max_rgb_value=255, ndigits: int | None = 2) -> 'ColorItem': """Set a rgb value :param r: red value @@ -102,7 +101,7 @@ def is_off(self) -> bool: """Return true if item is off""" return self.brightness <= 0 - def __repr__(self): + def __repr__(self) -> str: return f'' @classmethod diff --git a/src/HABApp/core/items/tmp_data.py b/src/HABApp/core/items/tmp_data.py index ccd05f77..e7739e55 100644 --- a/src/HABApp/core/items/tmp_data.py +++ b/src/HABApp/core/items/tmp_data.py @@ -16,19 +16,19 @@ class TmpItemData: - def __init__(self): + def __init__(self) -> None: self.ts = datetime.now() self.update: typing.Set[BaseWatch] = set() self.change: typing.Set[BaseWatch] = set() - def add_tasks(self, update, change): + def add_tasks(self, update, change) -> None: self.ts = datetime.now() self.update.update(update) self.change.update(change) self.clean() - def clean(self): + def clean(self) -> None: # remove canceled for obj in (self.update, self.change): canceled = [k for k in obj if k.fut.is_canceled] @@ -60,7 +60,7 @@ def restore_tmp_data(item: 'BaseItem'): item._last_change.tasks.append(t) -async def clean_tmp_data(): +async def clean_tmp_data() -> None: now = datetime.now() diff_max = timedelta(seconds=CLEANUP.secs) diff --git a/src/HABApp/core/lib/__init__.py b/src/HABApp/core/lib/__init__.py index f2ed5876..216c938b 100644 --- a/src/HABApp/core/lib/__init__.py +++ b/src/HABApp/core/lib/__init__.py @@ -1,9 +1,9 @@ -from . import parameters +from .exceptions import HINT_EXCEPTION, format_exception from .funcs import list_files, sort_files +from .instant_view import InstantView from .pending_future import PendingFuture -from .single_task import SingleTask -from .rgb_hsv import hsb_to_rgb, rgb_to_hsb -from .exceptions import format_exception, HINT_EXCEPTION from .priority_list import PriorityList +from .rgb_hsv import hsb_to_rgb, rgb_to_hsb +from .single_task import SingleTask from .timeout import Timeout, TimeoutNotRunningError from .value_change import ValueChange diff --git a/src/HABApp/core/lib/exceptions/format.py b/src/HABApp/core/lib/exceptions/format.py index b4dea245..b24aebee 100644 --- a/src/HABApp/core/lib/exceptions/format.py +++ b/src/HABApp/core/lib/exceptions/format.py @@ -1,15 +1,6 @@ from traceback import format_exception as _format_exception from types import TracebackType -from typing import Any, List, Tuple, Type, Union - -from HABApp.core.const.const import PYTHON_310 - - -if PYTHON_310: - from typing import TypeAlias -else: - from typing_extensions import TypeAlias - +from typing import Any, TypeAlias from stack_data import FrameInfo, Options @@ -17,7 +8,7 @@ from .format_frame import format_frame_info -def append_short_traceback(tb: List[str], e: Union[Exception, Tuple[Any, Any, Any]]): +def append_short_traceback(tb: list[str], e: Exception | tuple[Any, Any, Any]) -> None: for line in _format_exception(*e) if isinstance(e, tuple) else _format_exception(type(e), e, e.__traceback__): for sub_lines in line.splitlines(): tb.append(sub_lines.rstrip()) @@ -26,7 +17,7 @@ def append_short_traceback(tb: List[str], e: Union[Exception, Tuple[Any, Any, An DEFAULT_OPTIONS = Options(include_signature=True, max_lines_per_piece=5) -def fallback_format(e: Exception, existing_traceback: List[str]) -> List[str]: +def fallback_format(e: Exception, existing_traceback: list[str]) -> list[str]: # in case something goes wrong while formatting the traceback # we still want to show at least a small error message! new_tb = [f'Error while formatting traceback: {e}'] @@ -43,11 +34,11 @@ def fallback_format(e: Exception, existing_traceback: List[str]) -> List[str]: return new_tb -HINT_EXCEPTION: TypeAlias = Union[Exception, Tuple[Type[Exception], Exception, TracebackType]] +HINT_EXCEPTION: TypeAlias = Exception | tuple[type[Exception], Exception, TracebackType] -def format_exception(e: HINT_EXCEPTION) -> List[str]: - tb: List[str] = [] +def format_exception(e: HINT_EXCEPTION) -> list[str]: + tb: list[str] = [] try: all_frames = tuple(FrameInfo.stack_data(e[2] if isinstance(e, tuple) else e.__traceback__, DEFAULT_OPTIONS)) diff --git a/src/HABApp/core/lib/exceptions/format_frame.py b/src/HABApp/core/lib/exceptions/format_frame.py index 7e8836c3..d3fa661f 100644 --- a/src/HABApp/core/lib/exceptions/format_frame.py +++ b/src/HABApp/core/lib/exceptions/format_frame.py @@ -1,5 +1,4 @@ import re -from typing import List from stack_data import LINE_GAP, FrameInfo @@ -50,7 +49,7 @@ def is_lib_file(name: str) -> bool: return False -def format_frame_info(tb: List[str], frame_info: FrameInfo, is_last=False) -> bool: +def format_frame_info(tb: list[str], frame_info: FrameInfo, is_last=False) -> bool: filename = frame_info.filename # always skip system and python libraries diff --git a/src/HABApp/core/lib/exceptions/format_frame_vars.py b/src/HABApp/core/lib/exceptions/format_frame_vars.py index 8cc7259b..0a579e13 100644 --- a/src/HABApp/core/lib/exceptions/format_frame_vars.py +++ b/src/HABApp/core/lib/exceptions/format_frame_vars.py @@ -1,9 +1,12 @@ import ast import datetime +import importlib +from collections.abc import Callable from inspect import isclass, ismodule from pathlib import Path -from typing import Any, Callable, List, Set, Tuple +from typing import Any, Final +import whenever from easyconfig.config_objs import ConfigObj from immutables import Map from stack_data import Variable @@ -18,6 +21,9 @@ bool, bytearray, bytes, complex, dict, float, frozenset, int, list, memoryview, set, str, tuple, type(None), datetime.date, datetime.datetime, datetime.time, datetime.timedelta, Map, Path, + whenever.Instant, + whenever.SystemDateTime, whenever.LocalDateTime, whenever.ZonedDateTime, whenever.OffsetDateTime, + whenever.TimeDelta, whenever.DateDelta, whenever.DateTimeDelta ) @@ -25,7 +31,9 @@ def is_type_hint_or_type(value: Any) -> bool: if isinstance(value, tuple): return all(is_type_hint_or_type(obj) for obj in value) - if value in SKIPPED_TYPES: + # compare through identity since some objects override the equality operator. + # If we check with "value in SKIPPED_TYPES" we might get an exception because there the __eq__ operator is used + if any(value is o for o in SKIPPED_TYPES): return True # check if it's something from the typing module @@ -47,7 +55,21 @@ def _filter_expressions(name: str, value: Any) -> bool: return False -SKIP_VARIABLE: Tuple[Callable[[str, Any], bool], ...] = ( +SKIPPED_OBJS: Final = ( + 'HABApp.core.Items', +) + + +def _skip_objs(name: str, value: Any) -> bool: + for dotted_path in SKIPPED_OBJS: + path = dotted_path.split('.') + obj = importlib.import_module('.'.join(path[:-1])) + if value is getattr(obj, path[-1]): + return True + return False + + +SKIP_VARIABLE: tuple[Callable[[str, Any], bool], ...] = ( # module imports lambda name, value: ismodule(value), @@ -61,10 +83,10 @@ def _filter_expressions(name: str, value: Any) -> bool: lambda name, value: isinstance(value, ConfigObj), # Expressions - _filter_expressions + _filter_expressions, ) -ORDER_VARIABLE: Tuple[Callable[[Variable], bool], ...] = ( +ORDER_VARIABLE: tuple[Callable[[Variable], bool], ...] = ( lambda x: isclass(x.value), ) @@ -72,21 +94,23 @@ def _filter_expressions(name: str, value: Any) -> bool: def skip_variable(var: Variable) -> bool: name = var.name value = var.value - for func in SKIP_VARIABLE: - if func(name, value): - return True - return False + return any(func(name, value) for func in SKIP_VARIABLE) -def format_frame_variables(tb: List[str], stack_variables: List[Variable]): +def format_frame_variables(tb: list[str], stack_variables: list[Variable]): if not stack_variables: return None # remove variables that shall not be printed - used_vars: List[Variable] = [v for v in stack_variables if not skip_variable(v)] + used_vars: set[Variable] = {v for v in stack_variables if not skip_variable(v)} + + # remove objs and attributes + rem_obj_names = {v.name for v in used_vars if _skip_objs(v.name, v.value)} + rem_objs = {v for v in used_vars for rem_name in rem_obj_names if v.name.startswith(rem_name)} + used_vars -= rem_objs # attributes - dotted_names: Set[str] = {n.name.split('.')[0] for n in used_vars if '.' in n.name} + dotted_names: set[str] = {n.name.split('.')[0] for n in used_vars if '.' in n.name} # Sort output used_vars = sorted(used_vars, key=lambda x: ( diff --git a/src/HABApp/core/lib/funcs.py b/src/HABApp/core/lib/funcs.py index 3d0e3721..f3869dc2 100644 --- a/src/HABApp/core/lib/funcs.py +++ b/src/HABApp/core/lib/funcs.py @@ -1,6 +1,7 @@ import operator as _operator +from collections.abc import Iterable from pathlib import Path -from typing import TYPE_CHECKING, Iterable, List +from typing import TYPE_CHECKING from HABApp.core.const import MISSING @@ -10,13 +11,13 @@ def list_files(folder: Path, file_filter: 'HABApp.core.files.watcher.file_watcher.EventFilterBase', - recursive: bool = False) -> List[Path]: + recursive: bool = False) -> list[Path]: # glob is much quicker than iter_dir() files = folder.glob('**/*' if recursive else '*') return sorted(filter(lambda x: file_filter.notify(str(x)), files), key=lambda x: x.relative_to(folder)) -def sort_files(files: Iterable[Path]) -> List[Path]: +def sort_files(files: Iterable[Path]) -> list[Path]: return sorted(files) diff --git a/src/HABApp/core/lib/instant_view.py b/src/HABApp/core/lib/instant_view.py new file mode 100644 index 00000000..6e41f8a9 --- /dev/null +++ b/src/HABApp/core/lib/instant_view.py @@ -0,0 +1,140 @@ +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 + + +class InstantView: + __slots__ = ('_instant', ) + + def __init__(self, instant: Instant) -> None: + self._instant: Final = instant + + @classmethod + def now(cls) -> InstantView: + """Create a new instance with the current time""" + return cls(Instant.now()) + + def delta_now(self, now: InstantView | Instant | None = None) -> TimeDelta: + """Return the delta between now and the instant. + The delta will be positive, e.g. + if the InstantView is from 5 seconds ago this will return a timedelta with 5 seconds. + + :param now: optional instant to compare to instead of now, must be newer than the instant of the instant view + """ + + match now: + case None: + ref = Instant.now() + case InstantView(): + ref = now._instant + case Instant(): + ref = now + case _: + msg = f'Invalid type: {type(now).__name__}' + raise TypeError(msg) + + if ref < self._instant: + msg = f'Reference instant must be newer than the instant of the {self.__class__.__name__}' + raise ValueError(msg) + + return ref - self._instant + + def py_timedelta(self, now: InstantView | Instant | None = None) -> dt_timedelta: + """Return the timedelta between the instant and now + + :param now: optional instant to compare to + """ + return self.delta_now(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: + + td: TimeDelta | None = None + + 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) + + if td is None: + if isinstance(obj, InstantView): + obj = obj._instant + if isinstance(obj, Instant): + # If the compare the instant the logic is the other way around + # view > delta(3) -> view older than 3 seconds + # view > instant(-3) -> view newer than the instant 3 seconds ago + return {ge: le, gt: lt, le: ge, lt: gt}[op](self._instant, obj) + + if td is None: + 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(gt, other) + + def __le__(self, other: HINT_OBJ) -> bool: + return self._cmp(ge, other) + + def __gt__(self, other: HINT_OBJ) -> bool: + return self._cmp(lt, other) + + def __ge__(self, other: HINT_OBJ) -> bool: + return self._cmp(le, 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 + + +HINT_OBJ: TypeAlias = dt_timedelta | TimeDelta | int | str | Instant | InstantView diff --git a/src/HABApp/core/lib/parameters/__init__.py b/src/HABApp/core/lib/parameters/__init__.py deleted file mode 100644 index 51ec9d6f..00000000 --- a/src/HABApp/core/lib/parameters/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .positive_time_diff import TH_POSITIVE_TIME_DIFF, get_positive_time_diff diff --git a/src/HABApp/core/lib/parameters/positive_time_diff.py b/src/HABApp/core/lib/parameters/positive_time_diff.py deleted file mode 100644 index a6bcdd4a..00000000 --- a/src/HABApp/core/lib/parameters/positive_time_diff.py +++ /dev/null @@ -1,22 +0,0 @@ -from datetime import timedelta -from typing import Union - - -TH_POSITIVE_TIME_DIFF = Union[int, float, timedelta] - - -def get_positive_time_diff(arg: TH_POSITIVE_TIME_DIFF, round_digits=None) -> Union[int, float]: - if isinstance(arg, timedelta): - diff = arg.total_seconds() - if round_digits is not None: - diff = round(diff, round_digits) - elif isinstance(arg, float): - diff = round(arg, round_digits) if round_digits is not None else arg - elif isinstance(arg, int): - diff = arg - else: - raise ValueError(f'Invalid type: {type(arg)}') - - if diff <= 0: - raise ValueError(f'Time difference must be positive! ({arg})') - return diff diff --git a/src/HABApp/core/lib/pending_future.py b/src/HABApp/core/lib/pending_future.py index 22109d0d..9135b1cf 100644 --- a/src/HABApp/core/lib/pending_future.py +++ b/src/HABApp/core/lib/pending_future.py @@ -1,7 +1,7 @@ import asyncio -import typing from asyncio import Task, create_task, run_coroutine_threadsafe, sleep -from typing import Any, Awaitable, Callable, Optional +from collections.abc import Awaitable, Callable +from typing import Any from HABApp.core.const import loop @@ -9,18 +9,18 @@ # TODO: switch to time.monotonic for measurements instead of fixed sleep time class PendingFuture: - def __init__(self, future: Callable[[], Awaitable[Any]], secs: typing.Union[int, float]): + def __init__(self, future: Callable[[], Awaitable[Any]], secs: int | float) -> None: assert asyncio.iscoroutinefunction(future), type(future) if not isinstance(secs, (int, float)) or secs < 0: raise ValueError(f'Pending time must be int/float and >= 0! Is: {secs} ({type(secs)})') self.func: Callable[[], Awaitable[Any]] = future self.secs = secs - self.task: Optional[Task] = None + self.task: Task | None = None self.is_canceled: bool = False - def cancel(self): + def cancel(self) -> None: self.is_canceled = True if self.task is not None: @@ -44,6 +44,6 @@ def reset(self, thread_safe=False): else: self.task = create_task(self.__countdown()) - async def __countdown(self): + async def __countdown(self) -> None: await sleep(self.secs) await self.func() diff --git a/src/HABApp/core/lib/priority_list.py b/src/HABApp/core/lib/priority_list.py index b8477a65..81e5872e 100644 --- a/src/HABApp/core/lib/priority_list.py +++ b/src/HABApp/core/lib/priority_list.py @@ -1,20 +1,13 @@ from __future__ import annotations -from typing import Generic, Iterator, Literal, Tuple, TypeVar, Union - -from HABApp.core.const.const import PYTHON_310 - - -if PYTHON_310: - from typing import TypeAlias -else: - from typing_extensions import TypeAlias +from collections.abc import Iterator +from typing import Generic, Literal, TypeAlias, TypeVar T = TypeVar('T') -T_PRIO: TypeAlias = Union[Literal['first', 'last'], int] -T_ENTRY: TypeAlias = Tuple[T_PRIO, T] +T_PRIO: TypeAlias = Literal['first', 'last'] | int +T_ENTRY: TypeAlias = tuple[T_PRIO, T] def sort_func(obj: T_ENTRY): @@ -26,10 +19,10 @@ def sort_func(obj: T_ENTRY): # TODO: Move this to the connection class PriorityList(Generic[T]): - def __init__(self): + def __init__(self) -> None: self._objs: list[T_ENTRY] = [] - def append(self, obj: T, priority: T_PRIO): + def append(self, obj: T, priority: T_PRIO) -> None: for o in self._objs: assert o[0] != priority, priority self._objs.append((priority, obj)) @@ -49,5 +42,5 @@ def reversed(self) -> Iterator[T]: for p, o in reversed(self._objs): yield o - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {list(self)}>' diff --git a/src/HABApp/core/lib/rgb_hsv.py b/src/HABApp/core/lib/rgb_hsv.py index 188883f2..0c170cf1 100644 --- a/src/HABApp/core/lib/rgb_hsv.py +++ b/src/HABApp/core/lib/rgb_hsv.py @@ -1,10 +1,9 @@ from colorsys import hsv_to_rgb as _hsv_to_rgb from colorsys import rgb_to_hsv as _rgb_to_hsv -from typing import Optional, Tuple, Union -def rgb_to_hsb(r: Union[int, float], g: Union[int, float], b: Union[int, float], - max_rgb_value: int = 255, ndigits: Optional[int] = 2) -> Tuple[float, float, float]: +def rgb_to_hsb(r: int | float, g: int | float, b: int | float, + max_rgb_value: int = 255, ndigits: int | None = 2) -> tuple[float, float, float]: """Convert from rgb to hsb/hsv :param r: red value @@ -27,7 +26,7 @@ def rgb_to_hsb(r: Union[int, float], g: Union[int, float], b: Union[int, float], return h, s, v -def hsb_to_rgb(h, s, b, max_rgb_value=255) -> Tuple[int, int, int]: +def hsb_to_rgb(h, s, b, max_rgb_value=255) -> tuple[int, int, int]: """Convert from rgb to hsv/hsb :param h: hue diff --git a/src/HABApp/core/lib/single_task.py b/src/HABApp/core/lib/single_task.py index b7393912..5d32aaf8 100644 --- a/src/HABApp/core/lib/single_task.py +++ b/src/HABApp/core/lib/single_task.py @@ -1,5 +1,6 @@ from asyncio import CancelledError, Task, current_task -from typing import Any, Awaitable, Callable, Final, Optional +from collections.abc import Awaitable, Callable +from typing import Any, Final from HABApp.core.const import loop @@ -8,19 +9,19 @@ class SingleTask: - def __init__(self, coro: Callable[[], Awaitable[Any]], name: Optional[str] = None): + def __init__(self, coro: Callable[[], Awaitable[Any]], name: str | None = None) -> None: if name is None: name = f'{self.__class__.__name__}_{coro.__name__}' self.coro: Final = coro self.name: Final = name - self.task: Optional[Task] = None + self.task: Task | None = None @property def is_running(self) -> bool: return self.task is not None - def cancel(self) -> Optional[Task]: + def cancel(self) -> Task | None: if (task := self.task) is None: return None @@ -33,7 +34,7 @@ def cancel(self) -> Optional[Task]: task.cancel() return task - async def cancel_wait(self): + async def cancel_wait(self) -> None: if task := self.cancel(): try: await task @@ -61,7 +62,7 @@ def start_if_not_running(self) -> Task: self.task = task = loop.create_task(self._task_wrap(), name=self.name) return task - async def _task_wrap(self): + async def _task_wrap(self) -> None: task = current_task(loop) # don't use try-finally because diff --git a/src/HABApp/core/lib/timeout.py b/src/HABApp/core/lib/timeout.py index 28f2cc78..45e02ac3 100644 --- a/src/HABApp/core/lib/timeout.py +++ b/src/HABApp/core/lib/timeout.py @@ -10,14 +10,14 @@ class TimeoutNotRunningError(Exception): class Timeout: __slots__ = ('_timeout', '_started') - def __init__(self, timeout: float, *, start: bool = True): + def __init__(self, timeout: float, *, start: bool = True) -> None: self._timeout: float = timeout if self._timeout <= 0: raise ValueError() self._started: float | None = None if not start else monotonic() - def __repr__(self): + def __repr__(self) -> str: decimals = 1 if self._timeout < 10 else 0 diff --git a/src/HABApp/core/lib/value_change.py b/src/HABApp/core/lib/value_change.py index 16ec02fa..8522f3f6 100644 --- a/src/HABApp/core/lib/value_change.py +++ b/src/HABApp/core/lib/value_change.py @@ -11,7 +11,7 @@ class ValueChange(Generic[T]): __slots__ = ('_value', 'changed') - def __init__(self): + def __init__(self) -> None: self._value: T | _MissingType = MISSING self.changed: bool = False @@ -49,6 +49,6 @@ def value(self) -> T: raise ValueError() return self._value - def __repr__(self): + def __repr__(self) -> str: now = self._value if self._value is not MISSING else repr(MISSING) return f'<{self.__class__.__name__} value: {now} changed: {self.changed}>' diff --git a/src/HABApp/core/logger.py b/src/HABApp/core/logger.py index bc68bc9d..5b3d9e27 100644 --- a/src/HABApp/core/logger.py +++ b/src/HABApp/core/logger.py @@ -1,5 +1,7 @@ import logging -import typing +from typing import Any + +from typing_extensions import Self from HABApp.core.const.topics import TOPIC_ERRORS as _T_ERRORS from HABApp.core.const.topics import TOPIC_INFOS as _T_INFOS @@ -11,7 +13,7 @@ post_event = uses_post_event() -def log_error(logger: logging.Logger, text: str): +def log_error(logger: logging.Logger, text: str) -> None: if '\n' in text: for line in text.splitlines(): logger.error(line) @@ -22,7 +24,7 @@ def log_error(logger: logging.Logger, text: str): ) -def log_warning(logger: logging.Logger, text: str): +def log_warning(logger: logging.Logger, text: str) -> None: if '\n' in text: for line in text.splitlines(): logger.warning(line) @@ -34,7 +36,7 @@ def log_warning(logger: logging.Logger, text: str): ) -def log_info(logger: logging.Logger, text: str): +def log_info(logger: logging.Logger, text: str) -> None: if '\n' in text: for line in text.splitlines(): logger.info(line) @@ -50,15 +52,15 @@ class HABAppLogger: _LEVEL: int _TOPIC: str - def __init__(self, log: logging.Logger): - self.lines: typing.List[str] = [] + def __init__(self, log: logging.Logger) -> None: + self.lines: list[str] = [] self.logger = log - def add(self, text: str, *args, **kwargs): + def add(self, text: str, *args: Any, **kwargs: Any) -> Self: self.lines.append(text.format(*args, **kwargs)) return self - def add_exception(self, e: Exception, add_traceback: bool = False): + def add_exception(self, e: Exception, add_traceback: bool = False) -> Self: if not add_traceback: for line in str(e).splitlines(): self.lines.append(line) @@ -80,7 +82,7 @@ def dump(self) -> bool: self.lines.clear() return True - def __bool__(self): + def __bool__(self) -> bool: return bool(self.lines) diff --git a/src/HABApp/core/shutdown.py b/src/HABApp/core/shutdown.py new file mode 100644 index 00000000..7bf89678 --- /dev/null +++ b/src/HABApp/core/shutdown.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import logging +import logging.handlers +import signal +import traceback +from asyncio import iscoroutinefunction, sleep +from dataclasses import dataclass +from types import BuiltinMethodType, FunctionType, MethodType +from typing import TYPE_CHECKING + +from HABApp.core.asyncio import async_context, create_task +from HABApp.core.const import loop + + +if TYPE_CHECKING: + from collections.abc import Callable, Coroutine + from typing import Any, NoReturn + + +@dataclass(frozen=True) +class ShutdownBase: + msg: str + last: bool + + async def run(self) -> NoReturn: + raise NotImplementedError() + + +@dataclass(frozen=True) +class ShutdownFunction(ShutdownBase): + func: Callable[[], Any] + + async def run(self) -> None: + self.func() + + +@dataclass(frozen=True) +class ShutdownAwaitable(ShutdownBase): + func: Callable[[], Coroutine[Any, Any, Any]] + + async def run(self) -> None: + await self.func() + + +_REGISTERED: tuple[ShutdownBase, ...] = () + +_REQUESTED: bool = False + + +def register(func: Callable[[], Any], *, last: bool = False, msg: str = '') -> None: + global _REGISTERED + + if last is not True and last is not False: + raise ValueError() + + if not isinstance(msg, str): + raise TypeError() + + if not msg: + msg = f'{func.__module__}.{func.__name__}' + + if iscoroutinefunction(func): + _REGISTERED += (ShutdownAwaitable(func=func, last=last, msg=msg), ) + elif isinstance(func, (FunctionType, MethodType, BuiltinMethodType)): + _REGISTERED += (ShutdownFunction(func=func, last=last, msg=msg), ) + else: + raise TypeError() + + +async def _shutdown() -> None: + global _REQUESTED + + if _REQUESTED: + return None + _REQUESTED = True + + + async_context.set('Shutdown') + + log = logging.getLogger('HABApp.Shutdown') + log.debug('Requested shutdown') + + objs = ( + *(obj for obj in _REGISTERED if not obj.last), + *(obj for obj in _REGISTERED if obj.last), + # shutdown of the event loop has to be the last thing that is done + # since stopping of the loop exits the program + ShutdownFunction(func=loop.stop, msg='Stopping asyncio loop', last=True) + ) + + for obj in objs: + try: + log.debug(f'{obj.msg}') + await obj.run() + log.debug('-> done!') + await sleep(0.02) + except Exception as ex: # noqa: PERF203 + log.error(ex) + tb = traceback.format_exc().splitlines() + for line in tb: + log.error(line) + + log.debug('Shutdown complete') + + +def request() -> None: + create_task(_shutdown()) + + +def is_requested() -> bool: + return _REQUESTED + + +def register_signal_handler() -> None: + def shutdown_handler(sig, frame) -> None: + print('Shutting down ...') + request() + + # register shutdown helper + signal.signal(signal.SIGINT, shutdown_handler) + signal.signal(signal.SIGTERM, shutdown_handler) diff --git a/src/HABApp/core/types/color.py b/src/HABApp/core/types/color.py index 66dec2e4..b43242c2 100644 --- a/src/HABApp/core/types/color.py +++ b/src/HABApp/core/types/color.py @@ -1,6 +1,7 @@ +from __future__ import annotations + from colorsys import hsv_to_rgb as _hsv_to_rgb from colorsys import rgb_to_hsv as _rgb_to_hsv -from typing import Optional, Tuple, Union from typing_extensions import Self @@ -12,7 +13,7 @@ class ColorType: class RGB(ColorType): _RGB_MAX: int = 2 ** 8 - 1 - def __init__(self, r: int, g: int, b: int): + def __init__(self, r: int, g: int, b: int) -> None: max_value = self._RGB_MAX if not 0 <= r <= max_value or not 0 <= g <= max_value or not 0 <= b <= max_value: raise ValueError() @@ -51,8 +52,8 @@ def b(self) -> int: """blue value""" return self._b - def replace(self, r: Optional[int] = None, g: Optional[int] = None, b: Optional[int] = None, - red: Optional[int] = None, green: Optional[int] = None, blue: Optional[int] = None) -> Self: + def replace(self, r: int | None = None, g: int | None = None, b: int | None = None, + red: int | None = None, green: int | None = None, blue: int | None = None) -> Self: """Create a new object with (optionally) replaced values. :param r: new red value @@ -84,7 +85,7 @@ def replace(self, r: Optional[int] = None, g: Optional[int] = None, b: Optional[ blue if blue is not None else self._b, ) - def __str__(self): + def __str__(self) -> str: return f'{self.__class__.__name__}({self._r:d}, {self._g:d}, {self._b})' def __eq__(self, other): @@ -94,7 +95,7 @@ def __eq__(self, other): return self == self.__class__.from_hsb(other) return NotImplemented - def __getitem__(self, item: Union[int, str]) -> int: + def __getitem__(self, item: int | str) -> int: if isinstance(item, int): if item == 0: return self._r @@ -118,7 +119,7 @@ def __getitem__(self, item: Union[int, str]) -> int: # ------------------------------------------------------------------------------------------------------------------ # Conversions # ------------------------------------------------------------------------------------------------------------------ - def to_hsb(self) -> 'HSB': + def to_hsb(self) -> HSB: """Create a new HSB object from this object :return: New HSB object @@ -128,7 +129,7 @@ def to_hsb(self) -> 'HSB': return HSB(h * HUE_FACTOR, s * PERCENT_FACTOR, v * PERCENT_FACTOR) @classmethod - def from_hsb(cls, obj: Union['HSB', Tuple[float, float, float]]) -> Self: + def from_hsb(cls, obj: HSB | tuple[float, float, float]) -> Self: """Return new Object from a HSB object for a hsb tuple :param obj: HSB object or tuple with HSB values @@ -158,7 +159,7 @@ class RGB32(RGB): class HSB(ColorType): - def __init__(self, hue: float, saturation: float, brightness: float): + def __init__(self, hue: float, saturation: float, brightness: float) -> None: if not 0 <= hue <= HUE_FACTOR or not 0 <= saturation <= PERCENT_FACTOR or not 0 <= brightness <= PERCENT_FACTOR: raise ValueError() @@ -196,9 +197,9 @@ def b(self) -> float: """brightness value""" return self._brightness - def replace(self, h: Optional[float] = None, s: Optional[float] = None, b: Optional[float] = None, - hue: Optional[float] = None, saturation: Optional[float] = None, - brightness: Optional[float] = None) -> Self: + def replace(self, h: float | None = None, s: float | None = None, b: float | None = None, + hue: float | None = None, saturation: float | None = None, + brightness: float | None = None) -> Self: """Create a new object with (optionally) replaced values. :param h: New hue value @@ -230,7 +231,7 @@ def replace(self, h: Optional[float] = None, s: Optional[float] = None, b: Optio brightness if brightness is not None else self._brightness, ) - def __str__(self): + def __str__(self) -> str: return f'{self.__class__.__name__}({self._hue:.2f}, {self._saturation:.2f}, {self._brightness:.2f})' def __eq__(self, other): @@ -240,7 +241,7 @@ def __eq__(self, other): self._brightness == other._brightness return NotImplemented - def __getitem__(self, item: Union[int, str]) -> float: + def __getitem__(self, item: int | str) -> float: if isinstance(item, int): if item == 0: return self._hue @@ -272,7 +273,7 @@ def to_rgb(self) -> RGB: return RGB.from_hsb(self) @classmethod - def from_rgb(cls, obj: Union[RGB, Tuple[int, int, int]]) -> Self: + def from_rgb(cls, obj: RGB | tuple[int, int, int]) -> Self: """Create an HSB object from an RGB object or an RGB tuple :param obj: HSB object or RGB tuple diff --git a/src/HABApp/core/wrapper.py b/src/HABApp/core/wrapper.py index c3d0043d..9ef8971a 100644 --- a/src/HABApp/core/wrapper.py +++ b/src/HABApp/core/wrapper.py @@ -2,14 +2,14 @@ import functools import logging import typing +from collections.abc import Callable from logging import Logger # noinspection PyProtectedMember from sys import _getframe as sys_get_frame -from typing import Callable, Union +from types import TracebackType -from HABApp.core.const.topics import TOPIC_ERRORS as TOPIC_ERRORS -from HABApp.core.const.topics import TOPIC_WARNINGS as TOPIC_WARNINGS +from HABApp.core.const.topics import TOPIC_ERRORS, TOPIC_WARNINGS from HABApp.core.events.habapp_events import HABAppException from HABApp.core.internals import uses_post_event from HABApp.core.lib import format_exception @@ -20,8 +20,8 @@ post_event = uses_post_event() -def process_exception(func: Union[Callable, str], e: Exception, - do_print=False, logger: logging.Logger = log): +def process_exception(func: Callable | str, e: Exception, + do_print=False, logger: logging.Logger = log) -> None: lines = format_exception(e) func_name = func if isinstance(func, str) else func.__name__ @@ -89,20 +89,20 @@ def f(*args, **kwargs): class ExceptionToHABApp: - def __init__(self, logger: typing.Optional[Logger] = None, log_level: int = logging.ERROR, - ignore_exception: bool = True): - self.log: typing.Optional[Logger] = logger + def __init__(self, logger: Logger | None = None, log_level: int = logging.ERROR, + ignore_exception: bool = True) -> None: + self.log: Logger | None = logger self.log_level = log_level self.ignore_exception: bool = ignore_exception self.raised_exception = False - self.proc_tb: typing.Optional[typing.Callable[[list], list]] = None + self.proc_tb: typing.Callable[[list], list] | None = None - def __enter__(self): + def __enter__(self) -> None: self.raised_exception = False - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None): # no exception -> we exit gracefully if exc_type is None and exc_val is None: return True diff --git a/src/HABApp/mqtt/__init__.py b/src/HABApp/mqtt/__init__.py index cb74775d..674c41a9 100644 --- a/src/HABApp/mqtt/__init__.py +++ b/src/HABApp/mqtt/__init__.py @@ -1,7 +1,8 @@ -from . import events -from . import items +from . import events, items, util + # isort: split + import HABApp.mqtt.interface_async import HABApp.mqtt.interface_sync diff --git a/src/HABApp/mqtt/connection/connection.py b/src/HABApp/mqtt/connection/connection.py index 4c2ba818..8add0739 100644 --- a/src/HABApp/mqtt/connection/connection.py +++ b/src/HABApp/mqtt/connection/connection.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from typing import Optional +from typing import TypeAlias from aiomqtt import Client, MqttError @@ -10,27 +10,22 @@ from HABApp.core.connections import AutoReconnectPlugin, BaseConnection, Connections, ConnectionStateToEventBusPlugin from HABApp.core.connections.base_connection import AlreadyHandledException from HABApp.core.connections.base_plugin import BaseConnectionPluginConnectedTask -from HABApp.core.const.const import PYTHON_310 log = logging.getLogger('HABApp.mqtt.connection') -if PYTHON_310: - from typing import TypeAlias -else: - from typing_extensions import TypeAlias -CONTEXT_TYPE: TypeAlias = Optional[Client] +CONTEXT_TYPE: TypeAlias = Client | None -def setup(): +def setup() -> None: config = HABApp.config.CONFIG.mqtt from HABApp.mqtt.connection.handler import CONNECTION_HANDLER from HABApp.mqtt.connection.publish import PUBLISH_HANDLER from HABApp.mqtt.connection.subscribe import SUBSCRIPTION_HANDLER - connection = Connections.add(MqttConnection()) + connection = Connections.add(CONNECTION) connection.register_plugin(CONNECTION_HANDLER, 0) connection.register_plugin(SUBSCRIPTION_HANDLER, 10) @@ -45,7 +40,7 @@ def setup(): class MqttConnection(BaseConnection): - def __init__(self): + def __init__(self) -> None: super().__init__('mqtt') self.context: CONTEXT_TYPE = None @@ -53,15 +48,18 @@ def is_silent_exception(self, e: Exception): return isinstance(e, MqttError) +CONNECTION = MqttConnection() + + class MqttPlugin(BaseConnectionPluginConnectedTask[MqttConnection]): - def __init__(self, task_name: str): + def __init__(self, task_name: str) -> None: super().__init__(self._mqtt_wrap_task, task_name) async def mqtt_task(self): raise NotImplementedError() - async def _mqtt_wrap_task(self): + async def _mqtt_wrap_task(self) -> None: connection = self.plugin_connection log = connection.log diff --git a/src/HABApp/mqtt/connection/handler.py b/src/HABApp/mqtt/connection/handler.py index 569c6939..56cb477f 100644 --- a/src/HABApp/mqtt/connection/handler.py +++ b/src/HABApp/mqtt/connection/handler.py @@ -15,7 +15,7 @@ class ConnectionHandler(BaseConnectionPlugin[MqttConnection]): - def __init__(self): + def __init__(self) -> None: super().__init__(name=CONNECTION_HANDLER_NAME) async def on_setup(self, connection: MqttConnection): @@ -61,7 +61,7 @@ async def on_setup(self, connection: MqttConnection): # clean_session=False ) - async def on_connecting(self, connection: MqttConnection, context: CONTEXT_TYPE): + async def on_connecting(self, connection: MqttConnection, context: CONTEXT_TYPE) -> None: assert context is not None connection.log.info(f'Connecting to {context._hostname}:{context._port}') @@ -69,7 +69,7 @@ async def on_connecting(self, connection: MqttConnection, context: CONTEXT_TYPE) connection.log.info('Connection successful') - async def on_disconnected(self, connection: MqttConnection, context: CONTEXT_TYPE): + async def on_disconnected(self, connection: MqttConnection, context: CONTEXT_TYPE) -> None: assert context is not None connection.log.info('Disconnected') diff --git a/src/HABApp/mqtt/connection/publish.py b/src/HABApp/mqtt/connection/publish.py index 00a47eaa..dc4af7d1 100644 --- a/src/HABApp/mqtt/connection/publish.py +++ b/src/HABApp/mqtt/connection/publish.py @@ -1,5 +1,7 @@ from asyncio import Queue -from typing import Optional, Union +from typing import Any + +from pydantic import BaseModel from HABApp.config import CONFIG from HABApp.config.models.mqtt import QOS @@ -10,10 +12,13 @@ class PublishHandler(MqttPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__(task_name='MqttPublish') - async def mqtt_task(self): + async def mqtt_task(self) -> None: + if CONFIG.mqtt.general.listen_only: + return None + connection = self.plugin_connection with connection.handle_exception(self.mqtt_task): client = self.plugin_connection.context @@ -34,27 +39,28 @@ async def mqtt_task(self): await client.publish(topic, value, qos, retain) queue.task_done() - async def on_connected(self): + async def on_connected(self) -> None: global QUEUE - QUEUE = Queue() + if not CONFIG.mqtt.general.listen_only: + QUEUE = Queue() await super().on_connected() - async def on_disconnected(self): + async def on_disconnected(self) -> None: global QUEUE await super().on_disconnected() QUEUE = None -QUEUE: Optional[Queue] = Queue() +QUEUE: Queue | None = Queue() PUBLISH_HANDLER = PublishHandler() -def async_publish(topic: Union[str, ItemRegistryItem], payload, qos: Optional[QOS] = None, - retain: Optional[bool] = None): +def async_publish(topic: str | ItemRegistryItem, payload: Any, qos: QOS | None = None, + retain: bool | None = None) -> None: """ Publish a value under a certain topic. If qos and/or retain is not set the value from the configuration file will be used. @@ -71,13 +77,16 @@ def async_publish(topic: Union[str, ItemRegistryItem], payload, qos: Optional[QO topic = topic.name # convert these to string - if isinstance(payload, (dict, list, set, frozenset)): + if isinstance(payload, BaseModel): + payload = payload.model_dump_json() + elif isinstance(payload, (dict, list, set, frozenset)): payload = dump_json(payload) queue.put_nowait((topic, payload, qos, retain)) + return None -def publish(topic: Union[str, ItemRegistryItem], payload, qos: Optional[QOS] = None, retain: Optional[bool] = None): +def publish(topic: str | ItemRegistryItem, payload: Any, qos: QOS | None = None, retain: bool | None = None) -> None: """ Publish a value under a certain topic. If qos and/or retain is not set the value from the configuration file will be used. diff --git a/src/HABApp/mqtt/connection/subscribe.py b/src/HABApp/mqtt/connection/subscribe.py index d8b7e83a..5457411b 100644 --- a/src/HABApp/mqtt/connection/subscribe.py +++ b/src/HABApp/mqtt/connection/subscribe.py @@ -1,6 +1,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, Iterable +from collections.abc import Iterable +from typing import TYPE_CHECKING, Any import HABApp from HABApp.config import CONFIG @@ -21,7 +22,7 @@ class SubscriptionHandler(MqttPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__(task_name='MqttSubscribe') self.runtime_subs: dict[str, int] = {} self.subscribed_to: dict[str, int] = {} @@ -29,7 +30,7 @@ def __init__(self): self.sub_task = SingleTask(self.apply_subscriptions, 'ApplySubscriptionsTask') async def interface_subscribe(self, topic_or_topics: str | Iterable[tuple[str, int | None]], - qos: QOS | None = None): + qos: QOS | None = None) -> None: """ Subscribe to a MQTT topic. Note that subscriptions made this way are volatile and will only remain until the next restart. @@ -49,7 +50,7 @@ async def interface_subscribe(self, topic_or_topics: str | Iterable[tuple[str, i if self.plugin_connection.context is not None: await self.apply_subscriptions() - async def interface_unsubscribe(self, topic_or_topics: str | Iterable[str]): + async def interface_unsubscribe(self, topic_or_topics: str | Iterable[str]) -> None: """ Unsubscribe from a MQTT topic @@ -91,7 +92,7 @@ async def unsubscribe(self, topics: list[str] | None): for topic in topics: self.subscribed_to.pop(topic) - async def apply_subscriptions(self): + async def apply_subscriptions(self) -> None: log = self.plugin_connection.log default_qos = SUBSCRIBE_CFG.qos @@ -104,8 +105,8 @@ async def apply_subscriptions(self): # Unsubscribing has the corresponding handling, so we call that every time if not self.plugin_connection.has_errors: # subscription from config - for topic, qos in CONFIG.mqtt.subscribe.topics: - target[topic] = qos if qos is not None else default_qos + for topic, qos in CONFIG.mqtt.subscribe.get_topic_qos(): + target[topic] = qos # runtime subscriptions overwrite the subscriptions from the config file for topic, qos in self.runtime_subs.items(): target[topic] = qos @@ -129,7 +130,7 @@ async def apply_subscriptions(self): log.debug('Subscriptions successfully updated') - async def on_connected(self): + async def on_connected(self) -> None: await super().on_connected() # Since we are freshly connected we have not yet subscribed to anything @@ -140,7 +141,7 @@ async def on_connected(self): self.sub_task.start_if_not_running() await self.sub_task.wait() - async def on_disconnected(self): + async def on_disconnected(self) -> None: await super().on_disconnected() await self.sub_task.cancel_wait() @@ -148,7 +149,7 @@ async def on_disconnected(self): if not self.plugin_connection.has_errors: await self.unsubscribe(None) - async def mqtt_task(self): + async def mqtt_task(self) -> None: client = self.plugin_connection.context assert client is not None @@ -199,7 +200,7 @@ def msg_to_event(topic: str, payload: Any, retain: bool): async_unsubscribe = SUBSCRIPTION_HANDLER.interface_unsubscribe -def subscribe(topic_or_topics: str | Iterable[tuple[str, int | None]], qos: QOS | None = None): +def subscribe(topic_or_topics: str | Iterable[tuple[str, int | None]], qos: QOS | None = None) -> None: """ Subscribe to a MQTT topic. Note that subscriptions made this way are volatile and will only remain until the next restart. @@ -210,7 +211,7 @@ def subscribe(topic_or_topics: str | Iterable[tuple[str, int | None]], qos: QOS run_func_from_async(async_subscribe(topic_or_topics, qos)) -def unsubscribe(topic_or_topics: str | Iterable[str]): +def unsubscribe(topic_or_topics: str | Iterable[str]) -> None: """ Unsubscribe from a MQTT topic diff --git a/src/HABApp/mqtt/events/mqtt_filters.py b/src/HABApp/mqtt/events/mqtt_filters.py index 089f2fd4..fb230b46 100644 --- a/src/HABApp/mqtt/events/mqtt_filters.py +++ b/src/HABApp/mqtt/events/mqtt_filters.py @@ -6,10 +6,10 @@ class MqttValueUpdateEventFilter(TypeBoundEventFilter): - def __init__(self, value: Any = MISSING): + def __init__(self, value: Any = MISSING) -> None: super().__init__(MqttValueUpdateEvent, value=value) class MqttValueChangeEventFilter(TypeBoundEventFilter): - def __init__(self, value: Any = MISSING, old_value: Any = MISSING): + def __init__(self, value: Any = MISSING, old_value: Any = MISSING) -> None: super().__init__(MqttValueChangeEvent, value=value, old_value=old_value) diff --git a/src/HABApp/mqtt/items/mqtt_item.py b/src/HABApp/mqtt/items/mqtt_item.py index 8bc17431..23e0e0bf 100644 --- a/src/HABApp/mqtt/items/mqtt_item.py +++ b/src/HABApp/mqtt/items/mqtt_item.py @@ -1,4 +1,6 @@ -from typing import Optional +from typing import Any + +from typing_extensions import override from HABApp.core.errors import ItemNotFoundException from HABApp.core.internals import uses_get_item, uses_item_registry @@ -36,7 +38,7 @@ def get_create_item(cls, name: str, initial_value=None) -> 'MqttItem': assert isinstance(item, cls), f'{cls} != {type(item)}' return item - def publish(self, payload, qos: Optional[int] = None, retain: Optional[bool] = None): + def publish(self, payload, qos: int | None = None, retain: bool | None = None): """ Publish the payload under the topic from the item. @@ -46,3 +48,11 @@ def publish(self, payload, qos: Optional[int] = None, retain: Optional[bool] = N """ return publish(self.name, payload, qos=qos, retain=retain) + + @override + def command_value(self, value: Any) -> None: + """Send a command to the topic, the same as publish + + :param value: value to be sent + """ + publish(self.name, value) diff --git a/src/HABApp/mqtt/items/mqtt_pair_item.py b/src/HABApp/mqtt/items/mqtt_pair_item.py index 23ad41be..1c7b063c 100644 --- a/src/HABApp/mqtt/items/mqtt_pair_item.py +++ b/src/HABApp/mqtt/items/mqtt_pair_item.py @@ -1,4 +1,3 @@ -from typing import Optional from HABApp.core.errors import ItemNotFoundException from HABApp.core.internals import uses_item_registry @@ -10,7 +9,7 @@ Items = uses_item_registry() -def build_write_topic(read_topic: str) -> Optional[str]: +def build_write_topic(read_topic: str) -> str | None: parts = read_topic.split('/') if parts[0] == 'zigbee2mqtt': parts.insert(-1, 'set') @@ -25,7 +24,7 @@ class MqttPairItem(MqttBaseItem): and a corresponding topic that is used to write values""" @classmethod - def get_create_item(cls, name: str, write_topic: Optional[str] = None, initial_value=None) -> 'MqttPairItem': + def get_create_item(cls, name: str, write_topic: str | None = None, initial_value=None) -> 'MqttPairItem': """Creates a new item in HABApp and returns it or returns the already existing one with the given name. HABApp tries to automatically derive the write topic from the item name. In cases where this does not work it can be specified manually. @@ -49,11 +48,11 @@ def get_create_item(cls, name: str, write_topic: Optional[str] = None, initial_v assert isinstance(item, cls), f'{cls} != {type(item)}' return item - def __init__(self, name: str, initial_value=None, write_topic: Optional[str] = None): + def __init__(self, name: str, initial_value=None, write_topic: str | None = None) -> None: super().__init__(name, initial_value) self.write_topic: str = write_topic - def publish(self, payload, qos: Optional[int] = None, retain: Optional[bool] = None): + def publish(self, payload, qos: int | None = None, retain: bool | None = None): """ Publish the payload under the write topic from the item. diff --git a/src/HABApp/mqtt/mqtt_interface.py b/src/HABApp/mqtt/mqtt_interface.py deleted file mode 100644 index 9b287cf8..00000000 --- a/src/HABApp/mqtt/mqtt_interface.py +++ /dev/null @@ -1,99 +0,0 @@ -import typing - -import paho.mqtt.client as mqtt - -import HABApp -from HABApp.core.const.json import dump_json -from HABApp.mqtt.connection.mqtt_connection import STATUS, log - - -def __is_connected() -> bool: - if STATUS.connected: - return True - - msg = 'Mqtt client not connected' - raise ConnectionError(msg) - - -def publish(topic: str, payload: typing.Any, - qos: typing.Optional[int] = None, retain: typing.Optional[bool] = None) -> int: - """ - Publish a value under a certain topic. - If qos and/or retain is not set the value from the configuration file will be used. - - :param topic: MQTT topic - :param payload: MQTT Payload - :param qos: QoS, can be 0, 1 or 2. If not specified value from configuration file will be used. - :param retain: retain message. If not specified value from configuration file will be used. - :return: 0 if successful - """ - - assert isinstance(topic, str), type(topic) - assert isinstance(qos, int) or qos is None, type(qos) - assert isinstance(retain, bool) or retain is None, type(retain) - - config = HABApp.config.CONFIG.mqtt - - if not __is_connected(): - return mqtt.MQTT_ERR_NO_CONN - if config.general.listen_only: - return 100 - - if qos is None: - qos = config.publish.qos - if retain is None: - retain = config.publish.retain - - # convert these to string - if isinstance(payload, (dict, list)): - payload = dump_json(payload) - - info = STATUS.client.publish(topic, payload, qos, retain) - if info.rc != mqtt.MQTT_ERR_SUCCESS: - log.error(f'Could not publish to "{topic}": {mqtt.error_string(info.rc)}') - return info - - -def subscribe(topic: str, qos: typing.Optional[int] = None) -> int: - """ - Subscribe to a MQTT topic. Note that subscriptions made this way are volatile and will only remain until - the next disconnect. - - :param topic: MQTT topic to subscribe to - :param qos: QoS, can be 0, 1 or 2. If not specified value from configuration file will be used. - :return: 0 if successful - """ - - assert isinstance(topic, str), type(topic) - assert isinstance(qos, int) or qos is None, type(qos) - - if not __is_connected(): - return mqtt.MQTT_ERR_NO_CONN - - # If no qos is specified load it from config - if qos is None: - qos = HABApp.config.CONFIG.mqtt.subscribe.qos - - res, mid = STATUS.client.subscribe(topic, qos) - if res != mqtt.MQTT_ERR_SUCCESS: - log.error(f'Could not subscribe to "{topic}": {mqtt.error_string(res)}') - return res - - -def unsubscribe(topic: str) -> int: - """ - Unsubscribe from a MQTT topic - - :param topic: MQTT topic - :return: 0 if successful - """ - - assert isinstance(topic, str), type(topic) - - if not __is_connected(): - return mqtt.MQTT_ERR_NO_CONN - - result, mid = STATUS.client.unsubscribe(topic) - if result != mqtt.MQTT_ERR_SUCCESS: - log.error(f'Could not unsubscribe from "{topic}": {mqtt.error_string(result)}') - return result diff --git a/src/HABApp/mqtt/mqtt_payload.py b/src/HABApp/mqtt/mqtt_payload.py index 83065160..8e32c299 100644 --- a/src/HABApp/mqtt/mqtt_payload.py +++ b/src/HABApp/mqtt/mqtt_payload.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Optional, Tuple +from typing import Any from aiomqtt import Message @@ -11,7 +11,7 @@ log = logging.getLogger(f'{TOPIC_EVENTS}.mqtt') -def get_msg_payload(msg: Message) -> Tuple[Optional[str], Any]: +def get_msg_payload(msg: Message) -> tuple[str | None, Any]: try: topic = msg.topic.value raw = msg.payload diff --git a/src/HABApp/mqtt/util/__init__.py b/src/HABApp/mqtt/util/__init__.py new file mode 100644 index 00000000..bdf133ce --- /dev/null +++ b/src/HABApp/mqtt/util/__init__.py @@ -0,0 +1 @@ +from .publish_options import MqttPublishOptions diff --git a/src/HABApp/mqtt/util/publish_options.py b/src/HABApp/mqtt/util/publish_options.py new file mode 100644 index 00000000..72358288 --- /dev/null +++ b/src/HABApp/mqtt/util/publish_options.py @@ -0,0 +1,59 @@ +from typing import Any, Final + +from typing_extensions import Self + +from HABApp.mqtt.interface_sync import publish + + +class MqttPublishOptions: + """Allows to store the topic, qos and retain settings for a topic. These values can then be used to publish + """ + def __init__(self, topic: str, qos: int | None = None, retain: bool | None = None) -> None: + if not isinstance(topic, str): + raise TypeError() + if not topic: + raise ValueError() + + self._topic: Final = topic + self._qos: Final = qos + self._retain: Final = retain + + @property + def topic(self) -> str: + """The topic""" + return self._topic + + @property + def qos(self) -> int | None: + """QOS""" + return self._qos + + @property + def retain(self) -> bool | None: + """Retain""" + return self._retain + + def publish(self, payload: Any) -> None: + """ + Publish a payload + + :param payload: MQTT Payload + """ + + return publish(self._topic, payload, qos=self._qos, retain=self._retain) + + def replace(self, topic: str | None = None, qos: int | None = None, retain: bool | None = None) -> Self: + """ + Replace the topic, qos and retain with the given values and return a new object. + + :param topic: New topic (if provided) + :param qos: New qos (if provided) + :param retain: New retain (if provided) + :return: New object + """ + + return self.__class__( + topic if topic is not None else self._topic, + qos if qos is not None else self._qos, + retain if retain is not None else self._retain + ) diff --git a/src/HABApp/openhab/connection/connection.py b/src/HABApp/openhab/connection/connection.py index a44bafb7..933e08e7 100644 --- a/src/HABApp/openhab/connection/connection.py +++ b/src/HABApp/openhab/connection/connection.py @@ -1,22 +1,16 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any, Optional +from typing import TYPE_CHECKING, Any, TypeAlias import aiohttp import HABApp from HABApp.core.connections import AutoReconnectPlugin, BaseConnection, Connections, ConnectionStateToEventBusPlugin -from HABApp.core.const.const import PYTHON_310 -from HABApp.core.items.base_valueitem import datetime -if PYTHON_310: - from typing import TypeAlias -else: - from typing_extensions import TypeAlias - if TYPE_CHECKING: + from HABApp.core.lib import InstantView from HABApp.openhab.items import OpenhabItem, Thing @@ -29,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] @@ -46,10 +40,10 @@ def new_context(cls, version: tuple[int, int, int], ) -CONTEXT_TYPE: TypeAlias = Optional[OpenhabContext] +CONTEXT_TYPE: TypeAlias = OpenhabContext | None -def setup(): +def setup() -> None: config = HABApp.config.CONFIG.openhab from HABApp.openhab.connection.handler import HANDLER as CONNECTION_HANDLER @@ -89,7 +83,7 @@ def setup(): class OpenhabConnection(BaseConnection): - def __init__(self): + def __init__(self) -> None: super().__init__('openhab') self.context: CONTEXT_TYPE = None diff --git a/src/HABApp/openhab/connection/handler/func_async.py b/src/HABApp/openhab/connection/handler/func_async.py index ccdc1c58..a87ec1da 100644 --- a/src/HABApp/openhab/connection/handler/func_async.py +++ b/src/HABApp/openhab/connection/handler/func_async.py @@ -2,22 +2,28 @@ import warnings from datetime import datetime -from typing import Any, List +from typing import Any from urllib.parse import quote as quote_url -from HABApp.core.const.json import decode_struct from HABApp.core.internals import ItemRegistryItem from HABApp.openhab.definitions.rest import ( ItemChannelLinkResp, + ItemChannelLinkRespList, ItemHistoryResp, ItemResp, + ItemRespList, PersistenceServiceResp, + PersistenceServiceRespList, RootResp, ShortItemResp, + ShortItemRespList, SystemInfoRootResp, ThingResp, + ThingRespList, TransformationResp, + TransformationRespList, ) +from HABApp.openhab.definitions.rest.habapp_data import get_api_vals, load_habapp_meta from HABApp.openhab.errors import ( ItemNotEditableError, ItemNotFoundError, @@ -31,7 +37,6 @@ TransformationsRequestError, ) -from ...definitions.rest.habapp_data import get_api_vals, load_habapp_meta from . import convert_to_oh_type from .handler import delete, get, post, put @@ -48,7 +53,7 @@ async def async_get_root() -> RootResp | None: if not (b := await resp.read()): return None - return decode_struct(b, type=RootResp) + return RootResp.model_validate_json(b) # ---------------------------------------------------------------------------------------------------------------------- @@ -71,18 +76,18 @@ async def async_get_system_info(): if not (b := await resp.read()): return None - return decode_struct(b, type=SystemInfoRootResp).system_info + return SystemInfoRootResp.model_validate_json(b).system_info # ---------------------------------------------------------------------------------------------------------------------- # /items # ---------------------------------------------------------------------------------------------------------------------- -async def async_get_items() -> list[ItemResp]: +async def async_get_items() -> tuple[ItemResp, ...]: resp = await get('/rest/items', params={'metadata': '.+'}) body = await resp.read() - return decode_struct(body, type=List[ItemResp]) + return ItemRespList.validate_json(body) async def async_get_item(item: str | ItemRegistryItem) -> ItemResp | None: @@ -95,14 +100,14 @@ async def async_get_item(item: str | ItemRegistryItem) -> ItemResp | None: body = await resp.read() - return decode_struct(body, type=ItemResp) + return ItemResp.model_validate_json(body) -async def async_get_all_items_state() -> list[ShortItemResp]: +async def async_get_all_items_state() -> tuple[ShortItemResp, ...]: resp = await get('/rest/items', params={'fields': 'name,state,type'}) body = await resp.read() - return decode_struct(body, type=List[ShortItemResp]) + return ShortItemRespList.validate_json(body) async def async_item_exists(item: str | ItemRegistryItem) -> bool: @@ -163,7 +168,7 @@ async def async_create_item(item_type: str, name: str, return ret.status < 300 -async def async_remove_metadata(item: str | ItemRegistryItem, namespace: str): +async def async_remove_metadata(item: str | ItemRegistryItem, namespace: str) -> bool: # noinspection PyProtectedMember item = item if isinstance(item, str) else item._name @@ -177,7 +182,7 @@ async def async_remove_metadata(item: str | ItemRegistryItem, namespace: str): return ret.status < 300 -async def async_set_metadata(item: str | ItemRegistryItem, namespace: str, value: str, config: dict): +async def async_set_metadata(item: str | ItemRegistryItem, namespace: str, value: str, config: dict) -> bool: # noinspection PyProtectedMember item = item if isinstance(item, str) else item._name @@ -191,7 +196,7 @@ async def async_set_metadata(item: str | ItemRegistryItem, namespace: str, value if ret.status == 404: raise ItemNotFoundError.from_name(item) - elif ret.status == 405: + if ret.status == 405: raise MetadataNotEditableError.create_text(item, namespace) return ret.status < 300 @@ -199,11 +204,11 @@ async def async_set_metadata(item: str | ItemRegistryItem, namespace: str, value # ---------------------------------------------------------------------------------------------------------------------- # /things # ---------------------------------------------------------------------------------------------------------------------- -async def async_get_things() -> list[ThingResp]: +async def async_get_things() -> tuple[ThingResp, ...]: resp = await get('/rest/things') body = await resp.read() - return decode_struct(body, type=List[ThingResp]) + return ThingRespList.validate_json(body) async def async_get_thing(thing: str | ItemRegistryItem) -> ThingResp: @@ -215,7 +220,7 @@ async def async_get_thing(thing: str | ItemRegistryItem) -> ThingResp: raise ThingNotFoundError.from_uid(thing) body = await resp.read() - return decode_struct(body, type=ThingResp) + return ThingResp.model_validate_json(body) async def async_set_thing_cfg(thing: str | ItemRegistryItem, cfg: dict[str, Any]): @@ -259,7 +264,8 @@ async def async_set_thing_enabled(thing: str | ItemRegistryItem, enabled: bool): async def async_purge_links(): resp = await post('/rest/purge') if resp.status != 200: - raise LinkRequestError('Unexpected error') + msg = 'Unexpected error' + raise LinkRequestError(msg) async def async_remove_obj_links(name: str | ItemRegistryItem) -> bool: @@ -277,14 +283,15 @@ async def async_remove_obj_links(name: str | ItemRegistryItem) -> bool: return True -async def async_get_links() -> list[ItemChannelLinkResp]: +async def async_get_links() -> tuple[ItemChannelLinkResp, ...]: resp = await get('/rest/links') if resp.status != 200: - raise LinkRequestError('Unexpected error') + msg = 'Unexpected error' + raise LinkRequestError(msg) body = await resp.read() - return decode_struct(body, type=List[ItemChannelLinkResp]) + return ItemChannelLinkRespList.validate_json(body) def __get_item_link_url(item: str | ItemRegistryItem, channel: str) -> str: @@ -302,7 +309,7 @@ async def async_get_link(item: str | ItemRegistryItem, channel: str) -> ItemChan resp = await get(__get_item_link_url(item, channel), log_404=False) if resp.status == 200: body = await resp.read() - return decode_struct(body, type=ItemChannelLinkResp) + return ItemChannelLinkResp.model_validate_json(body) if resp.status == 404: raise LinkNotFoundError.from_names(item, channel) @@ -357,25 +364,25 @@ async def async_remove_link(item: str | ItemRegistryItem, channel: str): # ---------------------------------------------------------------------------------------------------------------------- # /transformations # ---------------------------------------------------------------------------------------------------------------------- -async def async_get_transformations() -> list[TransformationResp]: +async def async_get_transformations() -> tuple[TransformationResp, ...]: resp = await get('/rest/transformations') if resp.status >= 300: raise TransformationsRequestError() body = await resp.read() - return decode_struct(body, type=List[TransformationResp]) + return TransformationRespList.validate_json(body) # ---------------------------------------------------------------------------------------------------------------------- # /persistence # ---------------------------------------------------------------------------------------------------------------------- -async def async_get_persistence_services() -> list[PersistenceServiceResp]: +async def async_get_persistence_services() -> tuple[PersistenceServiceResp, ...]: resp = await get('/rest/persistence') if resp.status >= 300: raise PersistenceRequestError() body = await resp.read() - return decode_struct(body, type=List[PersistenceServiceResp]) + return PersistenceServiceRespList.validate_json(body) async def async_get_persistence_data(item: str | ItemRegistryItem, persistence: str | None, @@ -399,7 +406,7 @@ async def async_get_persistence_data(item: str | ItemRegistryItem, persistence: raise PersistenceRequestError() body = await resp.read() - return decode_struct(body, type=ItemHistoryResp) + return ItemHistoryResp.model_validate_json(body) async def async_set_persistence_data(item: str | ItemRegistryItem, persistence: str | None, diff --git a/src/HABApp/openhab/connection/handler/func_sync.py b/src/HABApp/openhab/connection/handler/func_sync.py index fcc1ce7b..f49e54d2 100644 --- a/src/HABApp/openhab/connection/handler/func_sync.py +++ b/src/HABApp/openhab/connection/handler/func_sync.py @@ -91,7 +91,7 @@ def create_item(item_type: str, name: str, :return: True if item was created/updated """ - def validate(_in): + def validate(_in) -> None: assert isinstance(_in, str), type(_in) # limit values to special entries and validate parameters diff --git a/src/HABApp/openhab/connection/handler/handler.py b/src/HABApp/openhab/connection/handler/handler.py index 85bea717..43a17ec4 100644 --- a/src/HABApp/openhab/connection/handler/handler.py +++ b/src/HABApp/openhab/connection/handler/handler.py @@ -7,6 +7,7 @@ from aiohttp.hdrs import METH_DELETE, METH_GET, METH_POST, METH_PUT from HABApp.config import CONFIG +from HABApp.core import shutdown from HABApp.core.connections import BaseConnectionPlugin from HABApp.core.connections._definitions import CONNECTION_HANDLER_NAME from HABApp.core.connections.base_connection import AlreadyHandledException @@ -19,14 +20,14 @@ class ConnectionHandler(BaseConnectionPlugin[OpenhabConnection]): request: aiohttp.ClientSession._request - def __init__(self): + def __init__(self) -> None: super().__init__(name=CONNECTION_HANDLER_NAME) self.options: dict[str, Any] = {} self.read_only: bool = False self.online = False self.session: aiohttp.ClientSession | None = None - def update_cfg_general(self): + def update_cfg_general(self) -> None: self.read_only = CONFIG.openhab.general.listen_only async def on_setup(self, connection: OpenhabConnection): @@ -71,10 +72,10 @@ async def on_setup(self, connection: OpenhabConnection): ) self.request = self.session._request - async def on_connected(self): + async def on_connected(self) -> None: self.online = True - async def on_disconnected(self, connection: OpenhabConnection): + async def on_disconnected(self, connection: OpenhabConnection) -> None: self.online = False connection.context = None @@ -128,6 +129,10 @@ async def check_response(self, future: aiohttp.client._RequestContextManager, se resp = await future except Exception as e: self.plugin_connection.process_exception(e, None) + if self.session.closed: + # We can not recover from a closed session so we shutdown + self.plugin_connection.log.error('Session closed!') + shutdown.request() raise OpenhabDisconnectedError() from None if (status := resp.status) < 300: diff --git a/src/HABApp/openhab/connection/handler/helper.py b/src/HABApp/openhab/connection/handler/helper.py index 07a6a2a4..0c669610 100644 --- a/src/HABApp/openhab/connection/handler/helper.py +++ b/src/HABApp/openhab/connection/handler/helper.py @@ -3,11 +3,13 @@ from datetime import datetime from typing import Any -from HABApp.core.items import BaseValueItem +from pydantic import BaseModel +from whenever import Instant, LocalDateTime, OffsetDateTime, SystemDateTime, ZonedDateTime + from HABApp.core.types import HSB, RGB -def convert_to_oh_type(obj: Any, scientific_floats=False) -> str: +def convert_to_oh_type(obj: Any, scientific_floats: bool = False) -> str: if isinstance(obj, (str, int, bool)): return str(obj) @@ -25,11 +27,7 @@ def convert_to_oh_type(obj: Any, scientific_floats=False) -> str: if isinstance(obj, datetime): # Add timezone (if not yet defined) to string, then remote anything below ms. # 2018-11-19T09:47:38.284000+0100 -> 2018-11-19T09:47:38.284+0100 - out = obj.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S.%f%z') - return out - - if isinstance(obj, (set, list, tuple, frozenset)): - return ','.join(map(str, obj)) + return obj.astimezone(None).strftime('%Y-%m-%dT%H:%M:%S.%f%z') if obj is None: return 'NULL' @@ -41,13 +39,20 @@ def convert_to_oh_type(obj: Any, scientific_floats=False) -> str: # noinspection PyProtectedMember return f'{obj._hue:.2f},{obj._saturation:.2f},{obj._brightness:.2f}' - if isinstance(obj, BaseValueItem): - raise ValueError() + # https://whenever.readthedocs.io/en/latest/overview.html#iso-8601 + if isinstance(obj, (Instant, LocalDateTime, ZonedDateTime, OffsetDateTime, SystemDateTime)): + return obj.format_common_iso() + + if isinstance(obj, (list, tuple, set, frozenset)): + return ','.join(convert_to_oh_type(x, scientific_floats=scientific_floats) for x in obj) + + if isinstance(obj, BaseModel): + return obj.model_dump_json() - return str(obj) + raise ValueError() def map_null_str(value: str) -> str | None: - if value == 'NULL' or value == 'UNDEF': + if value in ('NULL', 'UNDEF'): return None return value diff --git a/src/HABApp/openhab/connection/plugins/events_sse.py b/src/HABApp/openhab/connection/plugins/events_sse.py index df302437..0dd1b512 100644 --- a/src/HABApp/openhab/connection/plugins/events_sse.py +++ b/src/HABApp/openhab/connection/plugins/events_sse.py @@ -23,17 +23,17 @@ class SseEventListenerPlugin(BaseConnectionPlugin[OpenhabConnection]): - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__(name) self.task: Final = SingleTask(self.sse_task, name='SSE Task') - async def on_connected(self): + async def on_connected(self) -> None: self.task.start() - async def on_disconnected(self): + async def on_disconnected(self) -> None: await self.task.cancel_wait() - async def sse_task(self): + async def sse_task(self) -> None: try: with AsyncContext('SSE'): # cache so we don't have to look up every event diff --git a/src/HABApp/openhab/connection/plugins/load_items.py b/src/HABApp/openhab/connection/plugins/load_items.py index 0dfa9815..eb480c15 100644 --- a/src/HABApp/openhab/connection/plugins/load_items.py +++ b/src/HABApp/openhab/connection/plugins/load_items.py @@ -24,8 +24,7 @@ if TYPE_CHECKING: - from datetime import datetime - + from HABApp.core.lib import InstantView from HABApp.openhab.definitions.rest import ThingResp @@ -35,7 +34,7 @@ class LoadOpenhabItemsPlugin(BaseConnectionPlugin[OpenhabConnection]): - async def on_connected(self, context: OpenhabContext): + async def on_connected(self, context: OpenhabContext) -> None: # The context will be created fresh for each connect if not context.created_items and not context.created_things: await self.load_items(context) @@ -57,7 +56,7 @@ async def on_connected(self, context: OpenhabContext): if not await self.sync_items(context): break else: - log.warning(f'Item state sync failed!') + log.warning('Item state sync failed!') if context.created_things: for d in delays: @@ -65,9 +64,9 @@ async def on_connected(self, context: OpenhabContext): if not await self.sync_things(context): break else: - log.warning(f'Thing sync failed!') + log.warning('Thing sync failed!') - async def load_items(self, context: OpenhabContext): + async def load_items(self, context: OpenhabContext) -> None: from HABApp.openhab.map_items import map_item OpenhabItem = HABApp.openhab.items.OpenhabItem @@ -99,7 +98,7 @@ async def load_items(self, context: OpenhabContext): 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) @@ -133,7 +132,7 @@ async def sync_items(self, context: OpenhabContext): log.debug('Item state sync complete') return synced - async def load_things(self, context: OpenhabContext): + async def load_things(self, context: OpenhabContext) -> None: Thing = HABApp.openhab.items.Thing # try to update things, too diff --git a/src/HABApp/openhab/connection/plugins/out.py b/src/HABApp/openhab/connection/plugins/out.py index 20538b64..5e7fd7a5 100644 --- a/src/HABApp/openhab/connection/plugins/out.py +++ b/src/HABApp/openhab/connection/plugins/out.py @@ -14,7 +14,7 @@ class OutgoingCommandsPlugin(BaseConnectionPlugin[OpenhabConnection]): - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__(name) self.add: bool = False @@ -23,25 +23,25 @@ def __init__(self, name: str | None = None): self.task_worker: Final = SingleTask(self.queue_worker, 'OhQueueWorker') self.task_watcher: Final = SingleTask(self.queue_watcher, 'OhQueueWatcher') - async def _clear_queue(self): + async def _clear_queue(self) -> None: try: while True: self.queue.get_nowait() except QueueEmpty: pass - async def on_connected(self): + async def on_connected(self) -> None: self.add = True self.task_worker.start() self.task_watcher.start() - async def on_disconnected(self): + async def on_disconnected(self) -> None: self.add = False await self.task_worker.cancel_wait() await self.task_watcher.cancel_wait() await self._clear_queue() - async def queue_watcher(self): + async def queue_watcher(self) -> None: log = self.plugin_connection.log first_msg_at = 150 @@ -68,7 +68,7 @@ async def queue_watcher(self): log_info(log, f'{size} messages in queue') # noinspection PyProtectedMember - async def queue_worker(self): + async def queue_worker(self) -> None: queue: Final = self.queue to_str: Final = convert_to_oh_type @@ -109,7 +109,7 @@ def async_send_command(self, item: str | ItemRegistryItem, state: Any): async_send_command: Final = OUTGOING_PLUGIN.async_send_command -def post_update(item: str | ItemRegistryItem, state: Any): +def post_update(item: str | ItemRegistryItem, state: Any) -> None: """ Post an update to the item @@ -121,7 +121,7 @@ def post_update(item: str | ItemRegistryItem, state: Any): run_func_from_async(async_post_update, item, state) -def send_command(item: str | ItemRegistryItem, command: Any): +def send_command(item: str | ItemRegistryItem, command: Any) -> None: """ Send the specified command to the item diff --git a/src/HABApp/openhab/connection/plugins/overview_broken_links.py b/src/HABApp/openhab/connection/plugins/overview_broken_links.py index 3a0dffb8..6c73aa01 100644 --- a/src/HABApp/openhab/connection/plugins/overview_broken_links.py +++ b/src/HABApp/openhab/connection/plugins/overview_broken_links.py @@ -18,7 +18,7 @@ class BrokenLinksPlugin(BaseConnectionPlugin[OpenhabConnection]): - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__(name) self.do_run = True diff --git a/src/HABApp/openhab/connection/plugins/overview_things.py b/src/HABApp/openhab/connection/plugins/overview_things.py index cb014ce9..59bfa5d9 100644 --- a/src/HABApp/openhab/connection/plugins/overview_things.py +++ b/src/HABApp/openhab/connection/plugins/overview_things.py @@ -19,7 +19,7 @@ class ThingOverviewPlugin(BaseConnectionPlugin[OpenhabConnection]): - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__(name) self.do_run = True diff --git a/src/HABApp/openhab/connection/plugins/ping.py b/src/HABApp/openhab/connection/plugins/ping.py index 2fb2cd3b..6698c922 100644 --- a/src/HABApp/openhab/connection/plugins/ping.py +++ b/src/HABApp/openhab/connection/plugins/ping.py @@ -22,7 +22,7 @@ class PingPlugin(BaseConnectionPlugin[OpenhabConnection]): - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__(name) self.task: Final = SingleTask(self.ping_worker, 'OhQueueWorker') @@ -49,7 +49,7 @@ async def on_connected(self): self.task.start() - async def on_disconnected(self): + async def on_disconnected(self) -> None: await self.task.cancel_wait() if self.listener is not None: @@ -66,7 +66,7 @@ async def ping_received(self, event: HABApp.openhab.events.ItemStateEvent): if self.next_value is None: self.next_value = round((monotonic() - self.timestamp_sent) * 1000, 1) - async def ping_worker(self): + async def ping_worker(self) -> None: try: log.debug('Ping started') diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/cfg_validator.py b/src/HABApp/openhab/connection/plugins/plugin_things/cfg_validator.py index ae6df480..df2d6340 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/cfg_validator.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/cfg_validator.py @@ -1,12 +1,12 @@ import re import typing +from collections.abc import Iterator from dataclasses import dataclass -from typing import Dict, Iterator, List, Optional, Union +from typing import Annotated from pydantic import AfterValidator, ConfigDict, Field, TypeAdapter, ValidationError, field_validator from pydantic import BaseModel as _BaseModel -from HABApp.core.const.const import PYTHON_310 from HABApp.core.logger import HABAppError from HABApp.openhab.connection.plugins.plugin_things.filters import ChannelFilter, ThingFilter from HABApp.openhab.connection.plugins.plugin_things.str_builder import StrBuilder @@ -15,12 +15,6 @@ from ._log import log -if PYTHON_310: - from typing import Annotated -else: - from typing_extensions import Annotated - - RE_VALID_NAME = re.compile(r'\w+') @@ -30,12 +24,12 @@ class UserItem: name: str label: str icon: str - groups: List[str] - tags: List[str] - link: Optional[str] - metadata: Dict[str, Dict[str, Union[str, int, float]]] + groups: list[str] + tags: list[str] + link: str | None + metadata: dict[str, dict[str, str | int | float]] - def get_oh_cfg(self) -> Dict[str, Union[str, dict, list]]: + def get_oh_cfg(self) -> dict[str, str | dict | list]: ret = {} for k in self.__annotations__: if k in ('link', 'metadata'): @@ -58,7 +52,7 @@ class BaseModel(_BaseModel): class MetadataCfg(BaseModel): value: str - config: Dict[str, typing.Any] = {} + config: dict[str, typing.Any] = {} def mk_str_builder(v: str) -> StrBuilder: @@ -73,9 +67,9 @@ class UserItemCfg(BaseModel): name: TypeStrBuilder label: TypeStrBuilder = '' icon: TypeStrBuilder = '' - groups: List[TypeStrBuilder] = [] - tags: List[TypeStrBuilder] = [] - metadata: Optional[Dict[str, MetadataCfg]] = None + groups: list[TypeStrBuilder] = [] + tags: list[TypeStrBuilder] = [] + metadata: dict[str, MetadataCfg] | None = None @field_validator('type') def validate_item_type(cls, v): @@ -109,7 +103,7 @@ def get_item(self, context: dict) -> UserItem: # metadata is nested if k == 'metadata': - v[k] = {k: v.dict() for k, v in val.items()} if val is not None else {} + v[k] = {k: v.model_dump() for k, v in val.items()} if val is not None else {} continue # resolve str wildcards @@ -122,13 +116,14 @@ def get_item(self, context: dict) -> UserItem: # ensure a valid item name, otherwise the creation will definitely fail v['name'] = name = v['name'].replace('ä', 'ae').replace('ö', 'oe').replace('ü', 'ue').replace(' ', '_') if not RE_VALID_NAME.fullmatch(name): - raise InvalidItemNameError(f'"{name}" is not a valid name for an item!\n (created for {context})') + msg = f'"{name}" is not a valid name for an item!\n (created for {context})' + raise InvalidItemNameError(msg) return UserItem(**v) class UserChannelCfg(BaseModel): - filter: List[ChannelFilter] - link_items: List[UserItemCfg] = Field(default_factory=list, alias='link items') + filter: list[ChannelFilter] + link_items: list[UserItemCfg] = Field(default_factory=list, alias='link items') model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) @@ -142,12 +137,12 @@ def get_items(self, context: dict) -> Iterator[UserItem]: class UserThingCfg(BaseModel): test: bool - filter: List[ThingFilter] + filter: list[ThingFilter] # order of the type hint matters: int, str! - thing_config: Dict[Union[int, str], Union[int, float, str, List[str]]] = Field(alias='thing config', + thing_config: dict[int | str, int | float | str | list[str]] = Field(alias='thing config', default_factory=dict) - create_items: List[UserItemCfg] = Field(alias='create items', default_factory=list) - channels: List[UserChannelCfg] = Field(default_factory=list) + create_items: list[UserItemCfg] = Field(alias='create items', default_factory=list) + channels: list[UserChannelCfg] = Field(default_factory=list) model_config = ConfigDict(populate_by_name=True, arbitrary_types_allowed=True) @@ -159,7 +154,7 @@ def get_items(self, context: dict) -> Iterator[UserItem]: return map(lambda x: x.get_item(context), self.create_items) -def create_filters(cls, v: Union[List[Dict[str, str]], Dict[str, str]]): +def create_filters(cls, v: list[dict[str, str]] | dict[str, str]): if isinstance(v, dict): v = [v] r = [] @@ -171,10 +166,10 @@ def create_filters(cls, v: Union[List[Dict[str, str]], Dict[str, str]]): return r -def validate_cfg(_in, filename: Optional[str] = None) -> Optional[List[UserThingCfg]]: +def validate_cfg(_in, filename: str | None = None) -> list[UserThingCfg] | None: try: if isinstance(_in, list): - return TypeAdapter(List[UserThingCfg]).validate_python(_in) + return TypeAdapter(list[UserThingCfg]).validate_python(_in) else: return [UserThingCfg.model_validate(_in)] except ValidationError as e: diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter.py b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter.py index 31c75f47..8fcdc213 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter.py @@ -1,4 +1,5 @@ -from typing import Dict, Final, Iterable, List, TypeVar +from collections.abc import Iterable +from typing import Final, TypeVar from HABApp.core.const.const import PYTHON_311 @@ -10,7 +11,7 @@ class ValueFormatter: - def __init__(self, value: str): + def __init__(self, value: str) -> None: self.value: Final = value def len(self): @@ -21,7 +22,7 @@ def format(self, width: int) -> str: class EmptyFormatter(ValueFormatter): - def __init__(self): + def __init__(self) -> None: super().__init__('') @@ -30,8 +31,8 @@ def __init__(self): class FormatterScope: def __init__(self, field_names: Iterable[str], - skip_alignment: Iterable[str] = (), min_width: Dict[str, int] = {}): - self.lines: List[Dict[str, TYPE_FORMATTER]] = [] + skip_alignment: Iterable[str] = (), min_width: dict[str, int] = {}) -> None: + self.lines: list[dict[str, TYPE_FORMATTER]] = [] self.keys: Final = tuple(field_names) # alignment options @@ -40,13 +41,13 @@ def __init__(self, field_names: Iterable[str], assert set(skip_alignment).issubset(self.keys) - def add(self, obj: Dict[str, TYPE_FORMATTER]) -> Self: + def add(self, obj: dict[str, TYPE_FORMATTER]) -> Self: assert set(obj.keys()).issubset(self.keys) self.lines.append(obj) return self - def get_indent_dict(self, ) -> Dict[str, int]: - columns: Dict[str, List[TYPE_FORMATTER]] = {key: [] for key in self.keys} + def get_indent_dict(self, ) -> dict[str, int]: + columns: dict[str, list[TYPE_FORMATTER]] = {key: [] for key in self.keys} for line_dict in self.lines: for key in self.keys: formatter = line_dict.get(key) @@ -71,14 +72,14 @@ def get_indent_dict(self, ) -> Dict[str, int]: return column_width - def get_lines(self) -> List[str]: + def get_lines(self) -> list[str]: if not self.lines: return [] column_width = self.get_indent_dict() ret_lines = [] - for line_dict in self.lines: # type: Dict[str, TYPE_FORMATTER] + for line_dict in self.lines: # type: dict[str, TYPE_FORMATTER] line_vals = [] for key, value_formatter in line_dict.items(): width = column_width.get(key, 0) diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter_builder.py b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter_builder.py index 20c30c22..a29f4ed8 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter_builder.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/formatter_builder.py @@ -1,4 +1,5 @@ -from typing import Any, Callable, Final, Optional +from collections.abc import Callable +from typing import Any, Final from HABApp.openhab.connection.plugins.plugin_things.cfg_validator import UserItem @@ -11,7 +12,7 @@ def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': class ConstValueFormatterBuilder(BuilderBase): - def __init__(self, value: str, condition: Optional[Callable] = None): + def __init__(self, value: str, condition: Callable | None = None) -> None: self.value: Final = value self.condition: Final = condition @@ -22,7 +23,7 @@ def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': class ValueFormatterBuilder(BuilderBase): - def __init__(self, name: str, fmt_value: str): + def __init__(self, name: str, fmt_value: str) -> None: self.name: Final = name self.fmt_value: Final = fmt_value @@ -42,7 +43,7 @@ def create_formatter(self, item_obj: UserItem) -> 'TYPE_FORMATTER': class MultipleValueFormatterBuilder(ValueFormatterBuilder): - def __init__(self, name: str, fmt_value: str, wrapped_by: str): + def __init__(self, name: str, fmt_value: str, wrapped_by: str) -> None: super().__init__(name, fmt_value) self.wrapped_by: Final = wrapped_by diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/writer.py b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/writer.py index cc795fff..26d9c243 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/writer.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/file_writer/writer.py @@ -1,6 +1,6 @@ import re +from collections.abc import Iterable from pathlib import Path -from typing import Dict, Iterable, List, Optional from HABApp.core.const.const import PYTHON_311 from HABApp.openhab.connection.plugins.plugin_things.cfg_validator import UserItem @@ -33,8 +33,8 @@ def brackets_needed(obj: UserItem): class ItemsFileWriter: - def __init__(self): - self.items: List[UserItem] = [] + def __init__(self) -> None: + self.items: list[UserItem] = [] def add_item(self, obj) -> Self: self.items.append(obj) @@ -44,9 +44,9 @@ def add_items(self, objs: Iterable[UserItem]) -> Self: self.items.extend(objs) return self - def group_items(self) -> List[List[UserItem]]: - grouped_items: Dict[Optional[str], List[UserItem]] = {} - not_grouped: List[UserItem] = [] + def group_items(self) -> list[list[UserItem]]: + grouped_items: dict[str | None, list[UserItem]] = {} + not_grouped: list[UserItem] = [] for item in self.items: if m := RE_GROUP_NAMES.match(item.name): grouped_items.setdefault(m.group(1), []).append(item) @@ -97,7 +97,7 @@ def generate(self) -> str: return '\n'.join(lines) - def create_file(self, file: Path): + def create_file(self, file: Path) -> bool: output = self.generate() diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/filters.py b/src/HABApp/openhab/connection/plugins/plugin_things/filters.py index 6b522633..88f80dbb 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/filters.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/filters.py @@ -1,13 +1,14 @@ import logging import re -from typing import Any, Dict, Iterator, List +from collections.abc import Iterator +from typing import Any from HABApp.openhab.definitions.helpers.log_table import Table from ._log import log -THING_ALIAS: Dict[str, str] = { +THING_ALIAS: dict[str, str] = { 'thing_uid': 'UID', 'thing_type': 'thingTypeUID', 'thing_location': 'location', @@ -16,7 +17,7 @@ 'editable': 'editable', } -CHANNEL_ALIAS: Dict[str, str] = { +CHANNEL_ALIAS: dict[str, str] = { 'channel_uid': 'uid', 'channel_type': 'channelTypeUID', 'channel_label': 'label', @@ -26,9 +27,9 @@ class BaseFilter: - KEYS: Dict[str, str] + KEYS: dict[str, str] - def __init__(self, key: str, regex: str): + def __init__(self, key: str, regex: str) -> None: self.key = key try: self.alias = self.KEYS[key] @@ -53,7 +54,7 @@ def matches(self, _dict: dict, test: bool) -> bool: ) return m - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} "{self.key}", "{self.search.pattern}">' @@ -65,11 +66,11 @@ class ChannelFilter(BaseFilter): KEYS = CHANNEL_ALIAS -def apply_filters(filters: List[BaseFilter], iterable: List[Dict[str, str]], test: bool) -> Iterator[Dict[str, Any]]: +def apply_filters(filters: list[BaseFilter], iterable: list[dict[str, str]], test: bool) -> Iterator[dict[str, Any]]: return filter(lambda n: all(map(lambda filter: filter.matches(n, test), filters)), iterable) -def log_overview(data: List[dict], aliases: Dict[str, str], heading=''): +def log_overview(data: list[dict], aliases: dict[str, str], heading='') -> None: table = Table(heading) for k in aliases: table.add_column(k, align='<') diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/item_worker.py b/src/HABApp/openhab/connection/plugins/plugin_things/item_worker.py index 9b8b8e28..baeecf71 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/item_worker.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/item_worker.py @@ -1,4 +1,3 @@ -from typing import Dict, Set import HABApp from HABApp.openhab.connection.handler.func_async import ( @@ -19,7 +18,7 @@ from .cfg_validator import UserItem -def _filter_items(i: ItemResp): +def _filter_items(i: ItemResp) -> bool: if not i.editable: return False @@ -32,14 +31,14 @@ def _filter_items(i: ItemResp): return True -async def cleanup_items(keep_items: Set[str]): +async def cleanup_items(keep_items: set[str]): all_items = await async_get_items() - to_delete: Dict[str, HABAppThingPluginData] = {} - for cfg in filter(_filter_items, all_items): + to_delete: dict[str, HABAppThingPluginData] = {} + for cfg in filter(_filter_items, all_items): # type: ItemResp name = cfg.name if name not in keep_items: - to_delete[name] = cfg['metadata']['HABApp'] + to_delete[name] = cfg.metadata['HABApp'] if not to_delete: return None @@ -49,7 +48,7 @@ async def cleanup_items(keep_items: Set[str]): await _remove_item(item, data) -async def _remove_item(item: str, data: HABAppThingPluginData): +async def _remove_item(item: str, data: HABAppThingPluginData) -> None: # remove created links if data.created_link is not None: log.debug(f'Removing link from {data.created_link} to {item}') diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/plugin_things.py b/src/HABApp/openhab/connection/plugins/plugin_things/plugin_things.py index 80208885..5061d3f9 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/plugin_things.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/plugin_things.py @@ -4,8 +4,6 @@ from pathlib import Path from typing import Any -import msgspec - import HABApp import HABApp.openhab.events from HABApp.core.connections import BaseConnectionPlugin @@ -35,7 +33,7 @@ class DuplicateItemError(Exception): class TextualThingConfigPlugin(BaseConnectionPlugin[OpenhabConnection]): - def __init__(self): + def __init__(self) -> None: super().__init__() self.created_items: dict[str, set[str]] = {} self.do_cleanup = PendingFuture(self.clean_items, 120) @@ -73,7 +71,7 @@ async def on_connected(self): await self.watcher.trigger_all() @HABApp.core.wrapper.ignore_exception - async def clean_items(self): + async def clean_items(self) -> None: items = set() for s in self.created_items.values(): items.update(s) @@ -81,7 +79,7 @@ async def clean_items(self): async def load_thing_data(self, always: bool) -> list[dict[str, Any]]: if always or not self.cache_cfg or time.time() - self.cache_ts > 20: - self.cache_cfg = [msgspec.to_builtins(k) for k in await HABApp.openhab.interface_async.async_get_things()] + self.cache_cfg = [k.model_dump(mode='json') for k in await HABApp.openhab.interface_async.async_get_things()] self.cache_ts = time.time() return self.cache_cfg diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/str_builder.py b/src/HABApp/openhab/connection/plugins/plugin_things/str_builder.py index 8b0d3aa0..60f20a67 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/str_builder.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/str_builder.py @@ -1,5 +1,5 @@ import re -from typing import Dict, Optional, Pattern, Tuple, Union +from re import Pattern from .filters import CHANNEL_ALIAS, THING_ALIAS @@ -10,9 +10,9 @@ class StrBuilder: - def __init__(self, value: str): + def __init__(self, value: str) -> None: self.template: str = value.strip() - self.regex: Dict[str, Tuple[str, Optional[Pattern], Optional[str]]] = {} + self.regex: dict[str, tuple[str, Pattern | None, str | None]] = {} for p in RE_PLACEHOLDERS.findall(value): m = RE_ACCESSOR.fullmatch(p) @@ -40,7 +40,7 @@ def __init__(self, value: str): assert accessor in THING_ALIAS or accessor in CHANNEL_ALIAS, accessor self.regex[f'{{{p}}}'] = accessor.strip(), regex, replace - def get_str(self, context: Dict[str, str]) -> str: + def get_str(self, context: dict[str, str]) -> str: out = self.template for search, p in self.regex.items(): accessor, regex, replace = p @@ -60,11 +60,11 @@ def get_str(self, context: Dict[str, str]) -> str: return out - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} "{self.template}">' -def apply_builder(_in: Union[dict, list], context: dict): +def apply_builder(_in: dict | list, context: dict): if isinstance(_in, StrBuilder): return _in.get_str(context) diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/thing_config.py b/src/HABApp/openhab/connection/plugins/plugin_things/thing_config.py index 740e8e59..fd56dce1 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/thing_config.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/thing_config.py @@ -57,16 +57,16 @@ def from_dict(cls, uid: str, _in: dict) -> 'ThingConfigChanger': continue return c - def __init__(self, uid: str): + def __init__(self, uid: str) -> None: self.uid: str = uid - self.alias: bidict.bidict[typing.Union[str, int], str] = bidict.bidict() + self.alias: bidict.bidict[str | int, str] = bidict.bidict() self.org: typing.Dict[str, typing.Any] = {} self.new: typing.Dict[str, typing.Any] = {} def __getitem__(self, key): return self.org[self.alias.get(key, key)] - def __setitem__(self, o_key, value): + def __setitem__(self, o_key, value) -> None: key = self.alias.get(o_key, o_key) if key not in self.org: raise KeyError(f'Parameter "{o_key}" does not exist for {self.uid}!') @@ -101,7 +101,7 @@ def __setitem__(self, o_key, value): return None self.new[key] = value - def __contains__(self, key): + def __contains__(self, key) -> bool: return self.alias.get(key, key) in self.org def get(self, key, default=None): diff --git a/src/HABApp/openhab/connection/plugins/plugin_things/thing_worker.py b/src/HABApp/openhab/connection/plugins/plugin_things/thing_worker.py index 11abd736..7aca35f7 100644 --- a/src/HABApp/openhab/connection/plugins/plugin_things/thing_worker.py +++ b/src/HABApp/openhab/connection/plugins/plugin_things/thing_worker.py @@ -1,5 +1,4 @@ import itertools -from typing import List from HABApp.core.logger import HABAppError from HABApp.openhab.definitions.helpers.log_table import Table @@ -8,7 +7,7 @@ from .thing_config import ThingConfigChanger -def show_config_overview(cfgs: List[ThingConfigChanger], all_params): +def show_config_overview(cfgs: list[ThingConfigChanger], all_params) -> None: t = Table(heading='Current configuration') c_p = t.add_column('Parameter') c_s = [t.add_column(c.uid, alias=':'.join(c.uid.split(':')[-2:]) if ':' in c.uid else None) for c in cfgs] diff --git a/src/HABApp/openhab/connection/plugins/wait_for_restore.py b/src/HABApp/openhab/connection/plugins/wait_for_restore.py index d43b250a..c9d89c50 100644 --- a/src/HABApp/openhab/connection/plugins/wait_for_restore.py +++ b/src/HABApp/openhab/connection/plugins/wait_for_restore.py @@ -3,13 +3,13 @@ import logging from asyncio import sleep +from HABApp.core import shutdown from HABApp.core.connections import BaseConnectionPlugin from HABApp.core.internals import uses_item_registry from HABApp.core.lib import ValueChange from HABApp.core.lib.timeout import Timeout from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext from HABApp.openhab.items import OpenhabItem -from HABApp.runtime import shutdown log = logging.getLogger('HABApp.openhab.startup') @@ -39,7 +39,7 @@ async def on_connected(self, context: OpenhabContext): log.debug('Some items are still None - waiting for initialisation') timeout = Timeout(4 * 60) - while not shutdown.requested and none_items.changed: + while not shutdown.is_requested() and none_items.changed: await sleep(3) # timeout so we start eventually diff --git a/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py b/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py index 0fbfbd8c..4cb9f811 100644 --- a/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py +++ b/src/HABApp/openhab/connection/plugins/wait_for_startlevel.py @@ -5,6 +5,7 @@ import HABApp import HABApp.core import HABApp.openhab.events +from HABApp.core import shutdown from HABApp.core.connections import BaseConnectionPlugin from HABApp.core.lib import Timeout, ValueChange from HABApp.openhab.connection.connection import OpenhabConnection, OpenhabContext @@ -13,7 +14,7 @@ class WaitForStartlevelPlugin(BaseConnectionPlugin[OpenhabConnection]): - def __init__(self, name: str | None = None): + def __init__(self, name: str | None = None) -> None: super().__init__(name) async def on_connected(self, context: OpenhabContext, connection: OpenhabConnection): @@ -42,7 +43,7 @@ async def __on_connected_new(self, context: OpenhabContext, connection: OpenhabC sleep_secs = 1 - while not HABApp.runtime.shutdown.requested: + while not shutdown.is_requested(): await asyncio.sleep(sleep_secs) sleep_secs = 1 @@ -79,7 +80,7 @@ async def __on_connected_new(self, context: OpenhabContext, connection: OpenhabC timeout.start() log.debug('Starting start level timeout') - if HABApp.runtime.shutdown.requested: + if shutdown.is_requested(): return None log.info('openHAB startup complete') diff --git a/src/HABApp/openhab/definitions/helpers/log_table.py b/src/HABApp/openhab/definitions/helpers/log_table.py index eba968f6..b6bc6540 100644 --- a/src/HABApp/openhab/definitions/helpers/log_table.py +++ b/src/HABApp/openhab/definitions/helpers/log_table.py @@ -1,25 +1,25 @@ from collections import OrderedDict -from typing import Any, Dict, List, Optional, Tuple, Union +from typing import Any class Column: wrap: int = 80 - def __init__(self, name: str, align: Optional[str] = None, alias: Optional[str] = None, wrap: Optional[int] = None): + def __init__(self, name: str, align: str | None = None, alias: str | None = None, wrap: int | None = None) -> None: self.name: str = name - self.alias: Optional[str] = alias + self.alias: str | None = alias - self.align: Optional[str] = align + self.align: str | None = align if wrap is not None: self.wrap = wrap self.width: int = len(name) if alias is None else len(alias) - self.entries: List[Tuple[Any, ...]] = [] + self.entries: list[tuple[Any, ...]] = [] def get_lines(self, pos: int) -> int: return len(self.entries[pos]) - def format_entry(self, pos: int, lines: int) -> List[str]: + def format_entry(self, pos: int, lines: int) -> list[str]: ret = [] objs = self.entries[pos] @@ -35,7 +35,7 @@ def format_entry(self, pos: int, lines: int) -> List[str]: ret.append(f.format(val)) return ret - def add(self, val): + def add(self, val) -> None: _res = [] if isinstance(val, (list, set, tuple)): _len = 0 @@ -55,25 +55,25 @@ def add(self, val): self.width = max(self.width, len(str(k))) self.entries.append(tuple(_res)) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}' class Table: - def __init__(self, heading: str = ''): - self.columns: Dict[str, Column] = OrderedDict() + def __init__(self, heading: str = '') -> None: + self.columns: dict[str, Column] = OrderedDict() self.heading: str = heading - def add_column(self, name: str, align: Optional[str] = None, alias: Optional[str] = None, - wrap: Optional[int] = None) -> Column: + def add_column(self, name: str, align: str | None = None, alias: str | None = None, + wrap: int | None = None) -> Column: self.columns[name] = c = Column(name, align, alias, wrap) return c - def add_dict(self, _in: dict): + def add_dict(self, _in: dict) -> None: for k, col in self.columns.items(): col.add(_in[k]) - def get_lines(self, sort_columns: List[Union[str, Column]] = None) -> List[str]: + def get_lines(self, sort_columns: list[str | Column] = None) -> list[str]: # check if all tables have the same length vals = list(self.columns.values()) len1 = len(vals[0].entries) @@ -117,7 +117,7 @@ def get_lines(self, sort_columns: List[Union[str, Column]] = None) -> List[str]: for t, i in sorted(lines_dict.items()): lines = max(map(lambda x: x.get_lines(i), self.columns.values())) - grid = tuple(map(lambda x: x.format_entry(i, lines), self.columns.values())) # type: Tuple[List[str], ...] + grid = tuple(map(lambda x: x.format_entry(i, lines), self.columns.values())) # type: tuple[list[str], ...] for col_i in range(lines): cols = [obj[col_i] for obj in grid] ret.append('| ' + ' | '.join(cols) + ' |') diff --git a/src/HABApp/openhab/definitions/helpers/persistence_data.py b/src/HABApp/openhab/definitions/helpers/persistence_data.py index 187472fc..acebbd22 100644 --- a/src/HABApp/openhab/definitions/helpers/persistence_data.py +++ b/src/HABApp/openhab/definitions/helpers/persistence_data.py @@ -1,5 +1,5 @@ from datetime import datetime -from typing import Optional, Union +from typing import Optional from fastnumbers import try_real @@ -11,8 +11,8 @@ class OpenhabPersistenceData: - def __init__(self): - self.data: dict[float, Union[int, float, str]] = {} + def __init__(self) -> None: + self.data: dict[float, int | float | str] = {} @classmethod def from_resp(cls, data: ItemHistoryResp) -> 'OpenhabPersistenceData': @@ -39,13 +39,13 @@ def get_data(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) ret[ts] = val return ret - def min(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> Optional[float]: + def min(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> float | None: return min(self.get_data(start_date, end_date).values(), default=None) - def max(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> Optional[float]: + def max(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> float | None: return max(self.get_data(start_date, end_date).values(), default=None) - def average(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> Optional[float]: + def average(self, start_date: OPTIONAL_DT = None, end_date: OPTIONAL_DT = None) -> float | None: values = list(self.get_data(start_date, end_date).values()) ct = len(values) if ct == 0: diff --git a/src/HABApp/openhab/definitions/rest/__init__.py b/src/HABApp/openhab/definitions/rest/__init__.py index e60351bb..944dca4d 100644 --- a/src/HABApp/openhab/definitions/rest/__init__.py +++ b/src/HABApp/openhab/definitions/rest/__init__.py @@ -1,8 +1,7 @@ -from .items import ShortItemResp, ItemResp -from .things import ThingResp, ChannelResp -from .links import ItemChannelLinkResp - +from .items import ItemResp, ItemRespList, ShortItemResp, ShortItemRespList +from .links import ItemChannelLinkResp, ItemChannelLinkRespList +from .persistence import ItemHistoryResp, PersistenceServiceResp, PersistenceServiceRespList from .root import RootResp from .systeminfo import SystemInfoRootResp -from .transformations import TransformationResp -from .persistence import ItemHistoryResp, PersistenceServiceResp +from .things import ChannelResp, ThingResp, ThingRespList +from .transformations import TransformationResp, TransformationRespList diff --git a/src/HABApp/openhab/definitions/rest/habapp_data.py b/src/HABApp/openhab/definitions/rest/habapp_data.py index 980aa80c..40921e56 100644 --- a/src/HABApp/openhab/definitions/rest/habapp_data.py +++ b/src/HABApp/openhab/definitions/rest/habapp_data.py @@ -1,5 +1,5 @@ import typing -from typing import ClassVar, List, Optional +from typing import ClassVar from pydantic import BaseModel @@ -9,8 +9,8 @@ class HABAppThingPluginData(BaseModel): obj_name: ClassVar[str] = 'ThingPlugin' - created_link: Optional[str] = None - created_ns: List[str] = [] + created_link: str | None = None + created_ns: list[str] = [] # keep this up to date @@ -27,5 +27,5 @@ def load_habapp_meta(data: ItemResp) -> ItemResp: return data -def get_api_vals(obj: typing.Union[HABAppThingPluginData]) -> typing.Tuple[str, dict]: +def get_api_vals(obj: HABAppThingPluginData) -> typing.Tuple[str, dict]: return obj.obj_name, obj.model_dump(exclude_defaults=True) diff --git a/src/HABApp/openhab/definitions/rest/items.py b/src/HABApp/openhab/definitions/rest/items.py index 8a7f3896..60a4d5a6 100644 --- a/src/HABApp/openhab/definitions/rest/items.py +++ b/src/HABApp/openhab/definitions/rest/items.py @@ -1,69 +1,77 @@ -from typing import Any, Dict, List, Optional, Union +from __future__ import annotations -from msgspec import Struct, field +from typing import Any + +from pydantic import BaseModel, Field, TypeAdapter # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/types/StateOption.java -class StateOptionResp(Struct): +class StateOptionResp(BaseModel): value: str - label: Optional[str] = None + label: str | None = None # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/types/StateDescription.java -class StateDescriptionResp(Struct, kw_only=True): - minimum: Union[int, float, None] = None - maximum: Union[int, float, None] = None - step: Union[int, float, None] = None - pattern: Optional[str] = None - read_only: bool = field(name='readOnly') - options: List[StateOptionResp] +class StateDescriptionResp(BaseModel): + minimum: int | float | None = None + maximum: int | float | None = None + step: int | float | None = None + pattern: str | None = None + read_only: bool = Field(alias='readOnly') + options: tuple[StateOptionResp, ...] # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/types/CommandOption.java -class CommandOptionResp(Struct): +class CommandOptionResp(BaseModel): command: str - label: Optional[str] = None + label: str | None = None -class CommandDescriptionResp(Struct): - command_options: List[CommandOptionResp] = field(name='commandOptions') +class CommandDescriptionResp(BaseModel): + command_options: tuple[CommandOptionResp, ...] = Field(alias='commandOptions') # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/items/dto/GroupFunctionDTO.java -class GroupFunctionResp(Struct): +class GroupFunctionResp(BaseModel): name: str - params: List[str] = [] + params: tuple[str, ...] = () -class ItemResp(Struct, kw_only=True): +class ItemResp(BaseModel): # ItemDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core/src/main/java/org/openhab/core/items/dto/ItemDTO.java type: str name: str - label: Optional[str] = None - category: Optional[str] = None - tags: List[str] - groups: List[str] = field(name='groupNames') + label: str | None = None + category: str | None = None + tags: tuple[str, ...] + groups: tuple[str, ...] = Field(alias='groupNames') # EnrichedItemDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/item/EnrichedItemDTO.java - link: Optional[str] = None + link: str | None = None state: str - transformed_state: Optional[str] = field(default=None, name='transformedState') - state_description: Optional[StateDescriptionResp] = field(default=None, name='stateDescription') - unit: Optional[str] = field(default=None, name='unitSymbol') - command_description: Optional[CommandDescriptionResp] = field(default=None, name='commandDescription') - metadata: Dict[str, Any] = {} + transformed_state: str | None = Field(default=None, alias='transformedState') + state_description: StateDescriptionResp | None = Field(default=None, alias='stateDescription') + unit: str | None = Field(default=None, alias='unitSymbol') + command_description: CommandDescriptionResp | None = Field(default=None, alias='commandDescription') + metadata: dict[str, Any] = {} editable: bool = True # EnrichedGroupItemDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/item/EnrichedGroupItemDTO.java - members: List['ItemResp'] = [] - group_type: Optional[str] = field(default=None, name='groupType') - group_function: Optional[GroupFunctionResp] = field(default=None, name='function') + members: tuple[ItemResp, ...] = () + group_type: str | None = Field(default=None, alias='groupType') + group_function: GroupFunctionResp | None = Field(default=None, alias='function') + + +ItemRespList = TypeAdapter(tuple[ItemResp, ...]) -class ShortItemResp(Struct): +class ShortItemResp(BaseModel): type: str name: str state: str + + +ShortItemRespList = TypeAdapter(tuple[ShortItemResp, ...]) diff --git a/src/HABApp/openhab/definitions/rest/links.py b/src/HABApp/openhab/definitions/rest/links.py index 978d8eab..b78f3e08 100644 --- a/src/HABApp/openhab/definitions/rest/links.py +++ b/src/HABApp/openhab/definitions/rest/links.py @@ -1,18 +1,21 @@ -from typing import Any, Dict +from typing import Any -from msgspec import Struct, field +from pydantic import BaseModel, Field, TypeAdapter -class ItemChannelLinkResp(Struct, kw_only=True): +class ItemChannelLinkResp(BaseModel): # AbstractLinkDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/dto/AbstractLinkDTO.java - item: str = field(name='itemName') + item: str = Field(alias='itemName') # ItemChannelLinkDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/link/dto/ItemChannelLinkDTO.java - channel: str = field(name='channelUID') - configuration: Dict[str, Any] = {} + channel: str = Field(alias='channelUID') + configuration: dict[str, Any] = {} # EnrichedItemChannelLinkDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/link/EnrichedItemChannelLinkDTO.java editable: bool + + +ItemChannelLinkRespList = TypeAdapter(tuple[ItemChannelLinkResp, ...]) diff --git a/src/HABApp/openhab/definitions/rest/persistence.py b/src/HABApp/openhab/definitions/rest/persistence.py index e8373bff..313d38e6 100644 --- a/src/HABApp/openhab/definitions/rest/persistence.py +++ b/src/HABApp/openhab/definitions/rest/persistence.py @@ -1,16 +1,17 @@ -from typing import List, Optional - -from msgspec import Struct, field +from pydantic import BaseModel, Field, TypeAdapter # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/PersistenceServiceDTO.java -class PersistenceServiceResp(Struct): +class PersistenceServiceResp(BaseModel): id: str - label: Optional[str] = None - type: Optional[str] = None + label: str | None = None + type: str | None = None + + +PersistenceServiceRespList = TypeAdapter(tuple[PersistenceServiceResp, ...]) -class DataPoint(Struct): +class DataPoint(BaseModel): time: int state: str @@ -18,8 +19,8 @@ class DataPoint(Struct): # ItemHistoryDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.persistence/src/main/java/org/openhab/core/persistence/dto/ItemHistoryDTO.java -class ItemHistoryResp(Struct): +class ItemHistoryResp(BaseModel): name: str - total_records: Optional[str] = field(default=None, name='totalrecords') - data_points: Optional[str] = field(default=None, name='datapoints') - data: List[DataPoint] = [] + total_records: str | None = Field(default=None, alias='totalrecords') + data_points: str | None = Field(default=None, alias='datapoints') + data: list[DataPoint] = [] diff --git a/src/HABApp/openhab/definitions/rest/root.py b/src/HABApp/openhab/definitions/rest/root.py index 72e8496b..c2b53ced 100644 --- a/src/HABApp/openhab/definitions/rest/root.py +++ b/src/HABApp/openhab/definitions/rest/root.py @@ -1,24 +1,22 @@ -from typing import List - -from msgspec import Struct +from pydantic import BaseModel, Field # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest/src/main/java/org/openhab/core/io/rest/internal/resources/beans/RootBean.java -class RuntimeResp(Struct, rename='camel'): +class RuntimeResp(BaseModel): version: str - build_string: str + build_string: str = Field(alias='buildString') -class LinkResp(Struct): +class LinkResp(BaseModel): type: str url: str -class RootResp(Struct, rename='camel'): +class RootResp(BaseModel): version: str locale: str - measurement_system: str - runtime_info: RuntimeResp - links: List[LinkResp] + measurement_system: str = Field(alias='measurementSystem') + runtime_info: RuntimeResp = Field(alias='runtimeInfo') + links: list[LinkResp] diff --git a/src/HABApp/openhab/definitions/rest/systeminfo.py b/src/HABApp/openhab/definitions/rest/systeminfo.py index 6db4aa92..d0ddee07 100644 --- a/src/HABApp/openhab/definitions/rest/systeminfo.py +++ b/src/HABApp/openhab/definitions/rest/systeminfo.py @@ -1,21 +1,23 @@ -from typing import Optional -from msgspec import Struct +from pydantic import BaseModel, ConfigDict, Field +from pydantic.alias_generators import to_camel # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest/src/main/java/org/openhab/core/io/rest/internal/resources/beans/SystemInfoBean.java -class SystemInfoResp(Struct, rename='camel', kw_only=True): +class SystemInfoResp(BaseModel): + model_config = ConfigDict(alias_generator=to_camel) + config_folder: str userdata_folder: str - log_folder: Optional[str] = None - java_version: Optional[str] = None - java_vendor: Optional[str] = None - java_vendor_version: Optional[str] = None - os_name: Optional[str] = None - os_version: Optional[str] = None - os_architecture: Optional[str] = None + log_folder: str | None = None + java_version: str | None = None + java_vendor: str | None = None + java_vendor_version: str | None = None + os_name: str | None = None + os_version: str | None = None + os_architecture: str | None = None available_processors: int free_memory: int total_memory: int @@ -24,5 +26,5 @@ class SystemInfoResp(Struct, rename='camel', kw_only=True): uptime: int = -1 # TODO: remove default if we go OH4.1 only -class SystemInfoRootResp(Struct, rename='camel'): - system_info: SystemInfoResp +class SystemInfoRootResp(BaseModel): + system_info: SystemInfoResp = Field(alias='systemInfo') diff --git a/src/HABApp/openhab/definitions/rest/things.py b/src/HABApp/openhab/definitions/rest/things.py index 57f9d10e..55ca5fbd 100644 --- a/src/HABApp/openhab/definitions/rest/things.py +++ b/src/HABApp/openhab/definitions/rest/things.py @@ -1,58 +1,61 @@ -from typing import Any, Dict, List, Optional +from typing import Any -from msgspec import Struct, field +from pydantic import BaseModel, Field, TypeAdapter from HABApp.openhab.definitions import ThingStatusDetailEnum, ThingStatusEnum -class ChannelResp(Struct, kw_only=True): +class ChannelResp(BaseModel): # ChannelDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/dto/ChannelDTO.java uid: str id: str - channel_type: Optional[str] = field(default=None, name='channelTypeUID') - item_type: Optional[str] = field(default=None, name='itemType') + channel_type: str | None = Field(default=None, alias='channelTypeUID') + item_type: str | None = Field(default=None, alias='itemType') kind: str label: str = '' description: str = '' - default_tags: List[str] = field(default=list, name='defaultTags') - properties: Dict[str, Any] = {} - configuration: Dict[str, Any] = {} - auto_update_policy: str = field(default='', name='autoUpdatePolicy') + default_tags: tuple[str, ...] = Field(default=tuple, alias='defaultTags') + properties: dict[str, Any] = {} + configuration: dict[str, Any] = {} + auto_update_policy: str = Field(default='', alias='autoUpdatePolicy') # EnrichedChannelDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/thing/EnrichedChannelDTO.java - linked_items: List[str] = field(name='linkedItems') + linked_items: tuple[str, ...] = Field(alias='linkedItems') # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/ThingStatusInfo.java -class ThingStatusResp(Struct): +class ThingStatusResp(BaseModel): status: ThingStatusEnum - detail: ThingStatusDetailEnum = field(name='statusDetail') - description: Optional[str] = None + detail: ThingStatusDetailEnum = Field(alias='statusDetail') + description: str | None = None # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/firmware/dto/FirmwareStatusDTO.java -class FirmwareStatusResp(Struct): +class FirmwareStatusResp(BaseModel): status: str - updatable_version: Optional[str] = field(default=None, name='updatableVersion') + updatable_version: str | None = Field(default=None, alias='updatableVersion') -class ThingResp(Struct, kw_only=True): +class ThingResp(BaseModel): # AbstractThingDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/dto/AbstractThingDTO.java label: str = '' - bridge_uid: Optional[str] = field(default=None, name='bridgeUID') - configuration: Dict[str, Any] = {} - properties: Dict[str, str] = {} - uid: str = field(name='UID') - thing_type: str = field(name='thingTypeUID') + bridge_uid: str | None = Field(default=None, alias='bridgeUID') + configuration: dict[str, Any] = {} + properties: dict[str, str] = {} + uid: str = Field(alias='UID') + thing_type: str = Field(alias='thingTypeUID') location: str = '' # EnrichedThingDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.core/src/main/java/org/openhab/core/io/rest/core/thing/EnrichedThingDTO.java - channels: List[ChannelResp] = [] - status: ThingStatusResp = field(name='statusInfo') - firmware_status: Optional[FirmwareStatusResp] = None + channels: tuple[ChannelResp, ...] = [] + status: ThingStatusResp = Field(alias='statusInfo') + firmware_status: FirmwareStatusResp | None = None editable: bool + + +ThingRespList = TypeAdapter(tuple[ThingResp, ...]) diff --git a/src/HABApp/openhab/definitions/rest/transformations.py b/src/HABApp/openhab/definitions/rest/transformations.py index dec164cd..6ebc5f85 100644 --- a/src/HABApp/openhab/definitions/rest/transformations.py +++ b/src/HABApp/openhab/definitions/rest/transformations.py @@ -1,14 +1,15 @@ -from typing import Dict - -from msgspec import Struct +from pydantic import BaseModel, TypeAdapter # Documentation of TransformationDTO # https://github.com/openhab/openhab-core/blob/main/bundles/org.openhab.core.io.rest.transform/src/main/java/org/openhab/core/io/rest/transform/TransformationDTO.java -class TransformationResp(Struct): +class TransformationResp(BaseModel): uid: str label: str type: str - configuration: Dict[str, str] + configuration: dict[str, str] editable: bool + + +TransformationRespList = TypeAdapter(tuple[TransformationResp, ...]) diff --git a/src/HABApp/openhab/definitions/values.py b/src/HABApp/openhab/definitions/values.py index b6adee6a..e609516d 100644 --- a/src/HABApp/openhab/definitions/values.py +++ b/src/HABApp/openhab/definitions/values.py @@ -1,5 +1,5 @@ from base64 import b64decode -from typing import Final, Tuple +from typing import Final from fastnumbers import real @@ -10,22 +10,22 @@ class OnOffValue(ComplexEventValue): ON: Final = 'ON' OFF: Final = 'OFF' - def __init__(self, value): + def __init__(self, value) -> None: super().__init__(value) assert value == OnOffValue.ON or value == OnOffValue.OFF, f'{value} ({type(value)})' self.on = value == 'ON' - def __str__(self): + def __str__(self) -> str: return self.value class PercentValue(ComplexEventValue): - def __init__(self, value: str): + def __init__(self, value: str) -> None: percent = float(value) assert 0 <= percent <= 100, f'{percent} ({type(percent)})' super().__init__(percent) - def __str__(self): + def __str__(self) -> str: return f'{self.value}%' @@ -33,12 +33,12 @@ class OpenClosedValue(ComplexEventValue): OPEN: Final = 'OPEN' CLOSED: Final = 'CLOSED' - def __init__(self, value): + def __init__(self, value) -> None: super().__init__(value) assert value == OpenClosedValue.OPEN or value == OpenClosedValue.CLOSED, f'{value} ({type(value)})' self.open = value == OpenClosedValue.OPEN - def __str__(self): + def __str__(self) -> str: return self.value @@ -46,25 +46,25 @@ class UpDownValue(ComplexEventValue): UP = 'UP' DOWN = 'DOWN' - def __init__(self, value): + def __init__(self, value) -> None: super().__init__(value) assert value == UpDownValue.UP or value == UpDownValue.DOWN, f'{value} ({type(value)})' self.up = value == UpDownValue.UP - def __str__(self): + def __str__(self) -> str: return self.value class HSBValue(ComplexEventValue): - def __init__(self, value: str): + def __init__(self, value: str) -> None: super().__init__(tuple(float(k) for k in value.split(','))) - def __str__(self): + def __str__(self) -> str: return f'{self.value[0]}°,{self.value[1]}%,{self.value[2]}%' class PointValue(ComplexEventValue): - def __init__(self, value: str): + def __init__(self, value: str) -> None: ret = [] for k in value.split(','): if k is None: @@ -73,7 +73,7 @@ def __init__(self, value: str): ret.append(float(k)) super().__init__(tuple(ret)) - def __str__(self): + def __str__(self) -> str: if len(self.value) == 2: return f'{self.value[0]},{self.value[1]},' else: @@ -83,7 +83,7 @@ def __str__(self): class QuantityValue(ComplexEventValue): @staticmethod - def split_unit(value: str) -> Tuple[str, str]: + def split_unit(value: str) -> tuple[str, str]: p = value.rfind(' ') # dimensionless has no unit if p < 0: @@ -92,13 +92,13 @@ def split_unit(value: str) -> Tuple[str, str]: unit = value[p + 1:] return val, unit - def __init__(self, value: str): + def __init__(self, value: str) -> None: value, unit = QuantityValue.split_unit(value) val = real(value) super().__init__(val) self.unit = unit - def __str__(self): + def __str__(self) -> str: return f'{self.value} {self.unit}' def __eq__(self, other): @@ -108,7 +108,7 @@ def __eq__(self, other): class RawValue(ComplexEventValue): - def __init__(self, value: str): + def __init__(self, value: str) -> None: # The data is in this format # data:image/png;base64,iVBORw0KGgo.... @@ -124,5 +124,5 @@ def __init__(self, value: str): # set the bytes as value super().__init__(b64decode(value[sep_enc + 1:])) - def __str__(self): + def __str__(self) -> str: return f'{self.type}' diff --git a/src/HABApp/openhab/events/channel_events.py b/src/HABApp/openhab/events/channel_events.py index 84ecaff1..ad450bc2 100644 --- a/src/HABApp/openhab/events/channel_events.py +++ b/src/HABApp/openhab/events/channel_events.py @@ -11,7 +11,7 @@ class ChannelTriggeredEvent(OpenhabEvent): event: str channel: str - def __init__(self, name: str = '', event: str = '', channel: str = ''): + def __init__(self, name: str = '', event: str = '', channel: str = '') -> None: super().__init__() self.name: str = name @@ -22,7 +22,7 @@ def __init__(self, name: str = '', event: str = '', channel: str = ''): def from_dict(cls, topic: str, payload: dict): return cls(topic[17:-10], payload['event'], payload['channel']) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, event: {self.event}>' @@ -36,7 +36,7 @@ class ChannelDescriptionChangedEvent(OpenhabEvent): field: str value: str - def __init__(self, name: str = '', field: str = '', value: str = ''): + def __init__(self, name: str = '', field: str = '', value: str = '') -> None: super().__init__() self.name: str = name @@ -47,5 +47,5 @@ def __init__(self, name: str = '', field: str = '', value: str = ''): def from_dict(cls, topic: str, payload: dict): return cls(topic[17:-19], payload['field'], payload['value']) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, field: {self.field}>' diff --git a/src/HABApp/openhab/events/event_filters.py b/src/HABApp/openhab/events/event_filters.py index 6feda76e..a6d98580 100644 --- a/src/HABApp/openhab/events/event_filters.py +++ b/src/HABApp/openhab/events/event_filters.py @@ -8,20 +8,20 @@ # TODO: Drop this when we go OH4.0 only class ItemStateEventFilter(TypeBoundEventFilter): - def __init__(self, value: Any = MISSING): + def __init__(self, value: Any = MISSING) -> None: super().__init__(ItemStateEvent, value=value) class ItemStateUpdatedEventFilter(TypeBoundEventFilter): - def __init__(self, value: Any = MISSING): + def __init__(self, value: Any = MISSING) -> None: super().__init__(ItemStateUpdatedEvent, value=value) class ItemStateChangedEventFilter(TypeBoundEventFilter): - def __init__(self, value: Any = MISSING, old_value: Any = MISSING): + def __init__(self, value: Any = MISSING, old_value: Any = MISSING) -> None: super().__init__(ItemStateChangedEvent, value=value, old_value=old_value) class ItemCommandEventFilter(TypeBoundEventFilter): - def __init__(self, value: Any = MISSING): + def __init__(self, value: Any = MISSING) -> None: super().__init__(ItemCommandEvent, value=value) diff --git a/src/HABApp/openhab/events/item_events.py b/src/HABApp/openhab/events/item_events.py index 4a5c3209..a07c3ce9 100644 --- a/src/HABApp/openhab/events/item_events.py +++ b/src/HABApp/openhab/events/item_events.py @@ -1,4 +1,4 @@ -from typing import Any, Final, FrozenSet, Optional +from typing import Any, Final import HABApp.core @@ -13,7 +13,7 @@ def from_dict(cls, topic: str, payload: dict): # openhab/items/NAME/state return cls(topic[14:-6], map_openhab_values(payload['type'], payload['value'])) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' @@ -24,7 +24,7 @@ def from_dict(cls, topic: str, payload: dict): # openhab/items/NAME/stateupdated return cls(topic[14:-13], map_openhab_values(payload['type'], payload['value'])) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' @@ -39,30 +39,18 @@ def from_dict(cls, topic: str, payload: dict): map_openhab_values(payload['oldType'], payload['oldValue']) ) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}, old_value: {self.old_value}>' -class ItemCommandEvent(OpenhabEvent): - """ - :ivar str name: - :ivar Any value: - """ - name: str - value: Any - - def __init__(self, name: str, value: Any): - super().__init__() - - self.name: str = name - self.value: Any = value +class ItemCommandEvent(OpenhabEvent, HABApp.core.events.ValueCommandEvent): @classmethod def from_dict(cls, topic: str, payload: dict): # smarthome/items/NAME/command return cls(topic[14:-8], map_openhab_values(payload['type'], payload['value'])) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' @@ -70,25 +58,25 @@ class ItemAddedEvent(OpenhabEvent): """ :ivar str name: :ivar str type: - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: + :ivar str | None label: + :ivar frozenset[str] tags: + :ivar frozenset[str] groups: """ name: str type: str - label: Optional[str] - tags: FrozenSet[str] - groups: FrozenSet[str] + label: str | None + tags: frozenset[str] + groups: frozenset[str] - def __init__(self, name: str, type: str, label: Optional[str], - tags: FrozenSet[str], group_names: FrozenSet[str]): + def __init__(self, name: str, type: str, label: str | None, + tags: frozenset[str], group_names: frozenset[str]) -> None: super().__init__() self.name: str = name self.type: str = type - self.label: Optional[str] = label - self.tags: FrozenSet[str] = tags - self.groups: FrozenSet[str] = group_names + self.label: str | None = label + self.tags: frozenset[str] = tags + self.groups: frozenset[str] = group_names @classmethod def from_dict(cls, topic: str, payload: dict): @@ -100,7 +88,7 @@ def from_dict(cls, topic: str, payload: dict): tags=frozenset(payload['tags']), group_names=frozenset(payload['groupNames']) ) - def __repr__(self): + def __repr__(self) -> str: tags = f' {{{", ".join(sorted(self.tags))}}}' if self.tags else '' grps = f' {{{", ".join(sorted(self.groups))}}}' if self.groups else '' return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}, tags:{tags}, groups:{grps}>' @@ -110,25 +98,25 @@ class ItemUpdatedEvent(OpenhabEvent): """ :ivar str name: :ivar str type: - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: + :ivar str | None label: + :ivar frozenset[str] tags: + :ivar frozenset[str] groups: """ name: str type: str - label: Optional[str] - tags: FrozenSet[str] - groups: FrozenSet[str] + label: str | None + tags: frozenset[str] + groups: frozenset[str] - def __init__(self, name: str, type: str, label: Optional[str], - tags: FrozenSet[str], group_names: FrozenSet[str]): + def __init__(self, name: str, type: str, label: str | None, + tags: frozenset[str], group_names: frozenset[str]) -> None: super().__init__() self.name: str = name self.type: str = type - self.label: Optional[str] = label - self.tags: FrozenSet[str] = tags - self.groups: FrozenSet[str] = group_names + self.label: str | None = label + self.tags: frozenset[str] = tags + self.groups: frozenset[str] = group_names @classmethod def from_dict(cls, topic: str, payload: dict): @@ -142,7 +130,7 @@ def from_dict(cls, topic: str, payload: dict): tags=frozenset(new['tags']), group_names=frozenset(new['groupNames']) ) - def __repr__(self): + def __repr__(self) -> str: tags = f' {{{", ".join(sorted(self.tags))}}}' if self.tags else '' grps = f' {{{", ".join(sorted(self.groups))}}}' if self.groups else '' return f'<{self.__class__.__name__} name: {self.name}, type: {self.type}, tags:{tags}, groups:{grps}>' @@ -154,7 +142,7 @@ class ItemRemovedEvent(OpenhabEvent): """ name: str - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__() self.name = name @@ -163,7 +151,7 @@ def from_dict(cls, topic: str, payload: dict): # smarthome/items/Test/removed return cls(topic[14:-8]) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}>' @@ -175,7 +163,7 @@ class ItemStatePredictedEvent(OpenhabEvent): name: str value: Any - def __init__(self, name: str, value: Any): + def __init__(self, name: str, value: Any) -> None: super().__init__() self.name: Final = name self.value: Final = value @@ -185,7 +173,7 @@ def from_dict(cls, topic: str, payload: dict): # 'openhab/items/NAME/statepredicted' return cls(topic[14:-15], map_openhab_values(payload['predictedType'], payload['predictedValue'])) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, value: {self.value}>' @@ -199,7 +187,7 @@ class GroupStateUpdatedEvent(OpenhabEvent, HABApp.core.events.ValueUpdateEvent): item: str value: Any - def __init__(self, name: str, item: str, value: Any): + def __init__(self, name: str, item: str, value: Any) -> None: super().__init__(name, value) self.item: Final = item @@ -209,7 +197,7 @@ def from_dict(cls, topic: str, payload: dict): parts = topic.split('/') return cls(parts[2], parts[3], map_openhab_values(payload['type'], payload['value'])) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, item: {self.item}, value: {self.value}>' @@ -225,7 +213,7 @@ class GroupStateChangedEvent(OpenhabEvent, HABApp.core.events.ValueChangeEvent): value: Any old_value: Any - def __init__(self, name: str, item: str, value: Any, old_value: Any): + def __init__(self, name: str, item: str, value: Any, old_value: Any) -> None: super().__init__(name, value, old_value) self.item: Final = item @@ -240,6 +228,6 @@ def from_dict(cls, topic: str, payload: dict): map_openhab_values(payload['oldType'], payload['oldValue']) ) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, item: {self.item}, ' \ f'value: {self.value}, old_value: {self.old_value}>' diff --git a/src/HABApp/openhab/events/thing_events.py b/src/HABApp/openhab/events/thing_events.py index 489f2737..8e3901ea 100644 --- a/src/HABApp/openhab/events/thing_events.py +++ b/src/HABApp/openhab/events/thing_events.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Final, List +from typing import Any, Final from ..definitions import ThingStatusDetailEnum, ThingStatusEnum from ..definitions.things import THING_STATUS_DEFAULT, THING_STATUS_DETAIL_DEFAULT @@ -18,7 +18,7 @@ class ThingStatusInfoEvent(OpenhabEvent): description: str def __init__(self, name: str = '', status: ThingStatusEnum = THING_STATUS_DEFAULT, - detail: ThingStatusDetailEnum = THING_STATUS_DETAIL_DEFAULT, description: str = ''): + detail: ThingStatusDetailEnum = THING_STATUS_DETAIL_DEFAULT, description: str = '') -> None: super().__init__() self.name: Final = name @@ -32,7 +32,7 @@ def from_dict(cls, topic: str, payload: dict): return cls(name=topic[15:-7], status=ThingStatusEnum(payload['status']), detail=ThingStatusDetailEnum(payload['statusDetail']), description=payload.get('description', '')) - def __repr__(self): + def __repr__(self) -> str: description = f', description: "{self.description:s}"' if self.description else '' return f'<{self.__class__.__name__} name: {self.name:s}, ' \ f'status: {self.status:s}, detail: {self.detail:s}{description:s}>' @@ -65,7 +65,7 @@ def __init__(self, name: str = '', description: str = '', old_status: ThingStatusEnum = THING_STATUS_DEFAULT, old_detail: ThingStatusDetailEnum = THING_STATUS_DETAIL_DEFAULT, - old_description: str = ''): + old_description: str = '') -> None: super().__init__() self.name: Final = name @@ -90,7 +90,7 @@ def from_dict(cls, topic: str, payload: dict): old_description=old.get('description', '') ) - def __repr__(self): + def __repr__(self) -> str: description = f', description: "{self.description:s}"' if self.description else '' old_description = f', old_description: "{self.old_description:s}"' if self.old_description else '' return f'<{self.__class__.__name__} name: {self.name}, ' \ @@ -101,16 +101,16 @@ def __repr__(self): class ThingConfigStatusInfoEvent(OpenhabEvent): """ :ivar str name: - :ivar Dict[str, str] config_messages: + :ivar dict[str, str] config_messages: """ name: str - config_messages: Dict[str, str] + config_messages: dict[str, str] - def __init__(self, name: str = '', config_messages: Dict[str, str] = None): + def __init__(self, name: str = '', config_messages: dict[str, str] = None) -> None: super().__init__() self.name: str = name - self.config_messages: Dict[str, str] = config_messages if config_messages is not None else {} + self.config_messages: dict[str, str] = config_messages if config_messages is not None else {} @classmethod def from_dict(cls, topic: str, payload: dict): @@ -121,7 +121,7 @@ def from_dict(cls, topic: str, payload: dict): name=name, config_messages={param_name: msg_type for d in msgs for param_name, msg_type in d.items()} ) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}, config_messages: {self.config_messages}>' @@ -133,7 +133,7 @@ class ThingFirmwareStatusInfoEvent(OpenhabEvent): name: str status: str - def __init__(self, name: str = '', status: str = ''): + def __init__(self, name: str = '', status: str = '') -> None: super().__init__() self.name: str = name self.status: str = status @@ -143,7 +143,7 @@ def from_dict(cls, topic: str, payload: dict): # 'openhab/things/zwave:device:controller:my_node/firmware/status' return cls(name=topic[15:-16], status=payload['firmwareStatus']) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name} status: {self.status}>' @@ -153,21 +153,21 @@ class ThingRegistryBaseEvent(OpenhabEvent): :ivar str type: :ivar str label: :ivar str location: - :ivar List[Dict[str, Any]] channels: - :ivar Dict[str, Any] configuration: - :ivar Dict[str, str] properties: + :ivar list[dict[str, Any]] channels: + :ivar dict[str, Any] configuration: + :ivar dict[str, str] properties: """ name: str type: str label: str location: str - channels: List[Dict[str, Any]] - configuration: Dict[str, Any] - properties: Dict[str, str] + channels: list[dict[str, Any]] + configuration: dict[str, Any] + properties: dict[str, str] def __init__(self, name: str, thing_type: str, label: str, location: str, - channels: List[Dict[str, Any]], configuration: Dict[str, Any], - properties: Dict[str, str]): + channels: list[dict[str, Any]], configuration: dict[str, Any], + properties: dict[str, str]) -> None: super().__init__() # use name instead of uuid @@ -192,7 +192,7 @@ def from_dict(cls, topic: str, payload: dict): properties=payload.get('properties', {}), ) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name: {self.name}>' @@ -206,7 +206,7 @@ class ThingRemovedEvent(ThingRegistryBaseEvent): class ThingUpdatedEvent(ThingRegistryBaseEvent): @classmethod - def from_dict(cls, topic: str, payload: List[Dict[str, Any]]): + def from_dict(cls, topic: str, payload: list[dict[str, Any]]): payload = payload[0] return cls( diff --git a/src/HABApp/openhab/item_to_reg.py b/src/HABApp/openhab/item_to_reg.py index 791b97eb..3a5b10ec 100644 --- a/src/HABApp/openhab/item_to_reg.py +++ b/src/HABApp/openhab/item_to_reg.py @@ -77,7 +77,7 @@ def remove_from_registry(name: str): MEMBERS: dict[str, set[str]] = {} -def fresh_item_sync(): +def fresh_item_sync() -> None: MEMBERS.clear() diff --git a/src/HABApp/openhab/items/base_item.py b/src/HABApp/openhab/items/base_item.py index bc855c7d..e4c81c38 100644 --- a/src/HABApp/openhab/items/base_item.py +++ b/src/HABApp/openhab/items/base_item.py @@ -1,7 +1,9 @@ import datetime -from typing import Any, FrozenSet, Mapping, NamedTuple, Optional, Type +from collections.abc import Mapping +from typing import Any, NamedTuple from immutables import Map +from typing_extensions import override from HABApp.core.const import MISSING from HABApp.core.items import BaseValueItem @@ -20,24 +22,24 @@ class OpenhabItem(BaseValueItem): :ivar str name: :ivar Any value: - :ivar Optional[str] label: - :ivar FrozenSet[str] tags: - :ivar FrozenSet[str] groups: + :ivar str | None label: + :ivar frozenset[str] tags: + :ivar frozenset[str] groups: :ivar Mapping[str, MetaData] metadata: """ def __init__(self, name: str, initial_value: Any = None, - label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), - metadata: Mapping[str, MetaData] = Map()): + label: str | None = None, tags: frozenset[str] = frozenset(), groups: frozenset[str] = frozenset(), + metadata: Mapping[str, MetaData] = Map()) -> None: super().__init__(name, initial_value) - self.label: Optional[str] = label - self.tags: FrozenSet[str] = tags - self.groups: FrozenSet[str] = groups + self.label: str | None = label + self.tags: frozenset[str] = tags + self.groups: frozenset[str] = groups self.metadata: Mapping[str, MetaData] = metadata @classmethod def from_oh(cls, name: str, value=None, - label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), + label: str | None = None, tags: frozenset[str] = frozenset(), groups: frozenset[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): if value is not None: value = cls._state_from_oh_str(value) @@ -47,14 +49,24 @@ def from_oh(cls, name: str, value=None, def _state_from_oh_str(state: str): raise NotImplementedError() - def oh_send_command(self, value: Any = MISSING): + def oh_send_command(self, value: Any = MISSING) -> None: """Send a command to the openHAB item :param value: (optional) value to be sent. If not specified the current item value will be used. """ send_command(self.name, self.value if value is MISSING else value) - def oh_post_update(self, value: Any = MISSING): + # For the openhab items HABApp internal commands make not much sense + # so we send the commands to openHAB + @override + def command_value(self, value: Any) -> None: + """Send a command to the openHAB item, the same as oh_send_command + + :param value: value to be sent + """ + send_command(self.name, value) + + def oh_post_update(self, value: Any = MISSING) -> None: """Post an update to the openHAB item :param value: (optional) value to be posted. If not specified the current item value will be used. @@ -95,9 +107,9 @@ def oh_post_update_if(self, new_value, *, equal=MISSING, eq=MISSING, not_equal=M return True return False - def get_persistence_data(self, persistence: Optional[str] = None, - start_time: Optional[datetime.datetime] = None, - end_time: Optional[datetime.datetime] = None): + def get_persistence_data(self, persistence: str | None = None, + start_time: datetime.datetime | None = None, + end_time: datetime.datetime | None = None): """Query historical data from the OpenHAB persistence service :param persistence: name of the persistence service (e.g. ``rrd4j``, ``mapdb``). If not set default will be used @@ -110,4 +122,4 @@ def get_persistence_data(self, persistence: Optional[str] = None, ) -HINT_TYPE_OPENHAB_ITEM = Type[OpenhabItem] +HINT_TYPE_OPENHAB_ITEM = type[OpenhabItem] diff --git a/src/HABApp/openhab/items/color_item.py b/src/HABApp/openhab/items/color_item.py index db4c005e..242de11a 100644 --- a/src/HABApp/openhab/items/color_item.py +++ b/src/HABApp/openhab/items/color_item.py @@ -1,13 +1,12 @@ -from typing import FrozenSet, Mapping, Optional, Tuple +from collections.abc import Mapping from immutables import Map from HABApp.core.lib import hsb_to_rgb, rgb_to_hsb +from HABApp.openhab.definitions import HSBValue, OnOffValue, PercentValue from HABApp.openhab.items.base_item import MetaData, OpenhabItem from HABApp.openhab.items.commands import OnOffCommand, PercentCommand -from ..definitions import HSBValue, OnOffValue, PercentValue - HUE_FACTOR = 360 PERCENT_FACTOR = 100 @@ -17,20 +16,20 @@ class ColorItem(OpenhabItem, OnOffCommand, PercentCommand): """ColorItem which accepts and converts the data types from OpenHAB :ivar str name: |oh_item_desc_name| - :ivar Tuple[float, float, float] value: |oh_item_desc_value| + :ivar tuple[float, float, float] value: |oh_item_desc_value| :ivar float hue: Hue part of the value :ivar float saturation: Saturation part of the value :ivar float brightness: Brightness part of the value - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ def __init__(self, name: str, h: float = 0.0, s: float = 0.0, b: float = 0.0, - label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), - metadata: Mapping[str, MetaData] = Map()): + label: str | None = None, tags: frozenset[str] = frozenset(), groups: frozenset[str] = frozenset(), + metadata: Mapping[str, MetaData] = Map()) -> None: super().__init__(name=name, initial_value=(h, s, b), label=label, tags=tags, groups=groups, metadata=metadata) self.hue: float = min(max(0.0, h), HUE_FACTOR) @@ -38,13 +37,13 @@ def __init__(self, name: str, h: float = 0.0, s: float = 0.0, b: float = 0.0, self.brightness: float = min(max(0.0, b), PERCENT_FACTOR) @staticmethod - def _state_from_oh_str(state: str) -> Tuple[float, float, float]: + def _state_from_oh_str(state: str) -> tuple[float, float, float]: h, s, b = state.split(',') return float(h), float(s), float(b) @classmethod - def from_oh(cls, name: str, value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), - groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + def from_oh(cls, name: str, value=None, label: str | None = None, tags: frozenset[str] = frozenset(), + groups: frozenset[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): if value is None: return cls(name, label=label, tags=tags, groups=groups, metadata=metadata) return cls( @@ -80,7 +79,7 @@ def set_value(self, hue=0.0, saturation=0.0, brightness=0.0): return super().set_value(new_value=(self.hue, self.saturation, self.brightness)) - def post_value(self, hue=0.0, saturation=0.0, brightness=0.0): + def post_value(self, hue=0.0, saturation=0.0, brightness=0.0) -> None: """Set a new value and post appropriate events on the HABApp event bus (``ValueUpdateEvent``, ``ValueChangeEvent``) @@ -95,7 +94,7 @@ def post_value(self, hue=0.0, saturation=0.0, brightness=0.0): brightness if brightness is not None else self.brightness) ) - def get_rgb(self, max_rgb_value=255) -> Tuple[int, int, int]: + def get_rgb(self, max_rgb_value=255) -> tuple[int, int, int]: """Return a rgb equivalent of the color :param max_rgb_value: the max value for rgb, typically 255 (default) or 65.536 @@ -103,7 +102,7 @@ def get_rgb(self, max_rgb_value=255) -> Tuple[int, int, int]: """ return hsb_to_rgb(self.hue, self.saturation, self.brightness, max_rgb_value=max_rgb_value) - def set_rgb(self, r, g, b, max_rgb_value=255, ndigits: Optional[int] = 2) -> 'ColorItem': + def set_rgb(self, r, g, b, max_rgb_value=255, ndigits: int | None = 2) -> 'ColorItem': """Set a rgb value :param r: red value @@ -139,5 +138,5 @@ def is_off(self) -> bool: """Return true if item is off""" return self.brightness <= 0 - def __repr__(self): + def __repr__(self) -> str: return f'' diff --git a/src/HABApp/openhab/items/commands.py b/src/HABApp/openhab/items/commands.py index e668ce3a..e9c3a476 100644 --- a/src/HABApp/openhab/items/commands.py +++ b/src/HABApp/openhab/items/commands.py @@ -20,28 +20,29 @@ def is_off(self: _HasNameAttr) -> bool: """Test value against off-value""" raise NotImplementedError() - def on(self: _HasNameAttr): + def on(self: _HasNameAttr) -> None: """Command item on""" send_command(self.name, ON) - def off(self: _HasNameAttr): + def off(self: _HasNameAttr) -> None: """Command item off""" send_command(self.name, OFF) class PercentCommand: - def percent(self: _HasNameAttr, value: float): + def percent(self: _HasNameAttr, value: float) -> None: """Command to value (in percent)""" - assert 0 <= value <= 100, value + if not 0 <= value <= 100: # noqa: PLR2004 + raise ValueError() send_command(self.name, str(value)) class UpDownCommand: - def up(self: _HasNameAttr): + def up(self: _HasNameAttr) -> None: """Command up""" send_command(self.name, UP) - def down(self: _HasNameAttr): + def down(self: _HasNameAttr) -> None: """Command down""" send_command(self.name, DOWN) diff --git a/src/HABApp/openhab/items/contact_item.py b/src/HABApp/openhab/items/contact_item.py index 71716674..68d3e715 100644 --- a/src/HABApp/openhab/items/contact_item.py +++ b/src/HABApp/openhab/items/contact_item.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Any, FrozenSet, Mapping, Optional +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any from HABApp.openhab.interface_sync import post_update from HABApp.openhab.items.base_item import MetaData, OpenhabItem @@ -10,8 +11,6 @@ if TYPE_CHECKING: - Optional = Optional - FrozenSet = FrozenSet Mapping = Mapping MetaData = MetaData @@ -26,9 +25,9 @@ class ContactItem(OpenhabItem): :ivar str name: |oh_item_desc_name| :ivar str value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ diff --git a/src/HABApp/openhab/items/datetime_item.py b/src/HABApp/openhab/items/datetime_item.py index 234cb539..8faa3e7c 100644 --- a/src/HABApp/openhab/items/datetime_item.py +++ b/src/HABApp/openhab/items/datetime_item.py @@ -1,13 +1,12 @@ +from collections.abc import Mapping from datetime import datetime -from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional +from typing import TYPE_CHECKING from HABApp.core.const.const import PYTHON_311 from HABApp.openhab.items.base_item import MetaData, OpenhabItem if TYPE_CHECKING: - Optional = Optional - FrozenSet = FrozenSet Mapping = Mapping MetaData = MetaData @@ -18,9 +17,9 @@ class DatetimeItem(OpenhabItem): :ivar str name: |oh_item_desc_name| :ivar datetime value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ diff --git a/src/HABApp/openhab/items/dimmer_item.py b/src/HABApp/openhab/items/dimmer_item.py index 1e153fe8..d1420030 100644 --- a/src/HABApp/openhab/items/dimmer_item.py +++ b/src/HABApp/openhab/items/dimmer_item.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional, Union +from collections.abc import Mapping +from typing import TYPE_CHECKING from fastnumbers import real @@ -10,9 +11,6 @@ if TYPE_CHECKING: - Union = Union - Optional = Optional - FrozenSet = FrozenSet Mapping = Mapping MetaData = MetaData @@ -21,11 +19,11 @@ class DimmerItem(OpenhabItem, OnOffCommand, PercentCommand): """DimmerItem which accepts and converts the data types from OpenHAB :ivar str name: |oh_item_desc_name| - :ivar Union[int, float] value: |oh_item_desc_value| + :ivar int | float value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @@ -60,7 +58,7 @@ def is_off(self) -> bool: def __str__(self) -> str: return str(self.value) - def __bool__(self): + def __bool__(self) -> bool: if self.value is None: raise ItemValueIsNoneError.from_item(self) return self.is_on() diff --git a/src/HABApp/openhab/items/group_item.py b/src/HABApp/openhab/items/group_item.py index 8550e9a5..5b506047 100644 --- a/src/HABApp/openhab/items/group_item.py +++ b/src/HABApp/openhab/items/group_item.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Any, FrozenSet, Mapping, Optional, Tuple +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any from HABApp.core.events import ComplexEventValue from HABApp.openhab.item_to_reg import get_members @@ -7,8 +8,6 @@ if TYPE_CHECKING: Any = Any - Optional = Optional - FrozenSet = FrozenSet Mapping = Mapping MetaData = MetaData @@ -19,9 +18,9 @@ class GroupItem(OpenhabItem): :ivar str name: |oh_item_desc_name| :ivar Any value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @@ -36,7 +35,7 @@ def set_value(self, new_value) -> bool: return super().set_value(new_value) @property - def members(self) -> Tuple[OpenhabItem, ...]: + def members(self) -> tuple[OpenhabItem, ...]: """Resolves and then returns all group members""" return get_members(self.name) diff --git a/src/HABApp/openhab/items/image_item.py b/src/HABApp/openhab/items/image_item.py index a5da4408..3dd16050 100644 --- a/src/HABApp/openhab/items/image_item.py +++ b/src/HABApp/openhab/items/image_item.py @@ -1,21 +1,19 @@ from base64 import b64encode -from typing import TYPE_CHECKING, Any, FrozenSet, Mapping, Optional +from collections.abc import Mapping +from typing import TYPE_CHECKING, Any from immutables import Map +from HABApp.openhab.definitions import RawValue from HABApp.openhab.items.base_item import MetaData, OpenhabItem -from ..definitions import RawValue - if TYPE_CHECKING: - Optional = Optional - FrozenSet = FrozenSet Mapping = Mapping MetaData = MetaData -def _convert_bytes(data: bytes, img_type: Optional[str]) -> str: +def _convert_bytes(data: bytes, img_type: str | None) -> str: assert isinstance(data, bytes), type(data) # try to automatically found out what kind of file we have @@ -34,29 +32,29 @@ class ImageItem(OpenhabItem): :ivar str name: |oh_item_desc_name| :ivar bytes value: |oh_item_desc_value| - :ivar Optional[str] image_type: image type (e.g. jpg or png) + :ivar str | None image_type: image type (e.g. jpg or png) - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ - def __init__(self, name: str, initial_value: Any = None, label: Optional[str] = None, - tags: FrozenSet[str] = frozenset(), groups: FrozenSet[str] = frozenset(), - metadata: Mapping[str, MetaData] = Map()): + def __init__(self, name: str, initial_value: Any = None, label: str | None = None, + tags: frozenset[str] = frozenset(), groups: frozenset[str] = frozenset(), + metadata: Mapping[str, MetaData] = Map()) -> None: super().__init__(name, initial_value, label, tags, groups, metadata) # this item is unique because we also save the image type and thus have two states - self.image_type: Optional[str] = None + self.image_type: str | None = None @staticmethod def _state_from_oh_str(state: str): return RawValue(state).value @classmethod - def from_oh(cls, name: str, value=None, label: Optional[str] = None, tags: FrozenSet[str] = frozenset(), - groups: FrozenSet[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): + def from_oh(cls, name: str, value=None, label: str | None = None, tags: frozenset[str] = frozenset(), + groups: frozenset[str] = frozenset(), metadata: Mapping[str, MetaData] = Map()): c = cls(name, value, label=label, tags=tags, groups=groups, metadata=metadata) if value is not None: @@ -77,7 +75,7 @@ def set_value(self, new_value) -> bool: # bytes return super().set_value(new_value.value) - def oh_post_update(self, data: bytes, img_type: Optional[str] = None): + def oh_post_update(self, data: bytes, img_type: str | None = None): """Post an update to an openHAB image with new image data. Image type is automatically detected, in rare cases when this does not work it can be set manually. @@ -86,7 +84,7 @@ def oh_post_update(self, data: bytes, img_type: Optional[str] = None): """ return super().oh_post_update(_convert_bytes(data, img_type)) - def oh_send_command(self, data: bytes, img_type: Optional[str] = None): + def oh_send_command(self, data: bytes, img_type: str | None = None): """Send a command to an openHAB image with new image data. Image type is automatically detected, in rare cases when this does not work it can be set manually. diff --git a/src/HABApp/openhab/items/number_item.py b/src/HABApp/openhab/items/number_item.py index 9926d315..075a85ca 100644 --- a/src/HABApp/openhab/items/number_item.py +++ b/src/HABApp/openhab/items/number_item.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional, Union +from collections.abc import Mapping +from typing import TYPE_CHECKING from fastnumbers import real @@ -8,9 +9,7 @@ if TYPE_CHECKING: - Union = Union MetaData = MetaData - FrozenSet = FrozenSet Mapping = Mapping @@ -18,16 +17,16 @@ class NumberItem(OpenhabItem): """NumberItem which accepts and converts the data types from OpenHAB :ivar str name: |oh_item_desc_name| - :ivar Union[int, float] value: |oh_item_desc_value| + :ivar int | float value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @property - def unit(self) -> Optional[str]: + def unit(self) -> str | None: """Return the item unit if it is a "Unit of Measurement" item else None""" if (unit := self.metadata.get('unit')) is None: return None @@ -50,7 +49,7 @@ def set_value(self, new_value) -> bool: raise InvalidItemValue.from_item(self, new_value) - def __bool__(self): + def __bool__(self) -> bool: if self.value is None: raise ItemValueIsNoneError.from_item(self) return bool(self.value) diff --git a/src/HABApp/openhab/items/rollershutter_item.py b/src/HABApp/openhab/items/rollershutter_item.py index d3d56b9f..2929f3bc 100644 --- a/src/HABApp/openhab/items/rollershutter_item.py +++ b/src/HABApp/openhab/items/rollershutter_item.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional, Union +from collections.abc import Mapping +from typing import TYPE_CHECKING from fastnumbers import real @@ -9,9 +10,6 @@ if TYPE_CHECKING: - Union = Union - Optional = Optional - FrozenSet = FrozenSet Mapping = Mapping MetaData = MetaData @@ -20,11 +18,11 @@ class RollershutterItem(OpenhabItem, UpDownCommand, PercentCommand): """RollershutterItem which accepts and converts the data types from OpenHAB :ivar str name: |oh_item_desc_name| - :ivar Union[int, float] value: |oh_item_desc_value| + :ivar int | float value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ diff --git a/src/HABApp/openhab/items/string_item.py b/src/HABApp/openhab/items/string_item.py index 0e4f3912..df8390d9 100644 --- a/src/HABApp/openhab/items/string_item.py +++ b/src/HABApp/openhab/items/string_item.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional +from collections.abc import Mapping +from typing import TYPE_CHECKING, Optional from HABApp.openhab.items.base_item import MetaData, OpenhabItem @@ -6,7 +7,6 @@ if TYPE_CHECKING: MetaData = MetaData Optional = Optional - FrozenSet = FrozenSet, Mapping = Mapping @@ -16,9 +16,9 @@ class StringItem(OpenhabItem): :ivar str name: |oh_item_desc_name| :ivar str value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @@ -33,9 +33,9 @@ class PlayerItem(OpenhabItem): :ivar str name: |oh_item_desc_name| :ivar str value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ diff --git a/src/HABApp/openhab/items/switch_item.py b/src/HABApp/openhab/items/switch_item.py index b6851d84..4924dae2 100644 --- a/src/HABApp/openhab/items/switch_item.py +++ b/src/HABApp/openhab/items/switch_item.py @@ -1,4 +1,5 @@ -from typing import TYPE_CHECKING, Final, FrozenSet, Mapping, Optional, Tuple +from collections.abc import Mapping +from typing import TYPE_CHECKING, Final from HABApp.core.errors import InvalidItemValue, ItemValueIsNoneError from HABApp.openhab.definitions import OnOffValue @@ -7,9 +8,6 @@ if TYPE_CHECKING: - Tuple = Tuple - Optional = Optional - FrozenSet = FrozenSet Mapping = Mapping MetaData = MetaData @@ -24,9 +22,9 @@ class SwitchItem(OpenhabItem, OnOffCommand): :ivar str name: |oh_item_desc_name| :ivar str value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @@ -82,7 +80,7 @@ def __eq__(self, other): return NotImplemented - def __bool__(self): + def __bool__(self) -> bool: if self.value is None: raise ItemValueIsNoneError.from_item(self) return self.value == ON diff --git a/src/HABApp/openhab/items/thing_item.py b/src/HABApp/openhab/items/thing_item.py index 409096d1..97c9c088 100644 --- a/src/HABApp/openhab/items/thing_item.py +++ b/src/HABApp/openhab/items/thing_item.py @@ -1,8 +1,8 @@ -from typing import Any, Mapping +from collections.abc import Mapping +from typing import Any from immutables import Map -from pendulum import UTC -from pendulum import now as pd_now +from whenever import Instant from HABApp.core.items import BaseItem from HABApp.openhab.definitions import ThingStatusDetailEnum, ThingStatusEnum @@ -22,7 +22,7 @@ class Thing(BaseItem): :ivar Mapping[str, Any] configuration: Thing configuration :ivar Mapping[str, Any] properties: Thing properties """ - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__(name) self.status: ThingStatusEnum = THING_STATUS_DEFAULT @@ -41,7 +41,7 @@ def is_enabled(self) -> bool: return self.status_detail != 'DISABLED' def __update_timestamps(self, changed: bool): - _now = pd_now(UTC) + _now = Instant.now() self._last_update.set(_now) if changed: self._last_change.set(_now) diff --git a/src/HABApp/openhab/items/tuple_items.py b/src/HABApp/openhab/items/tuple_items.py index 189534c8..77c94b29 100644 --- a/src/HABApp/openhab/items/tuple_items.py +++ b/src/HABApp/openhab/items/tuple_items.py @@ -1,13 +1,11 @@ -from typing import TYPE_CHECKING, FrozenSet, Mapping, Optional, Tuple +from collections.abc import Mapping +from typing import TYPE_CHECKING from HABApp.openhab.definitions.values import PointValue from HABApp.openhab.items.base_item import MetaData, OpenhabItem if TYPE_CHECKING: - Tuple = Tuple - Optional = Optional - FrozenSet = FrozenSet Mapping = Mapping MetaData = MetaData @@ -16,11 +14,11 @@ class CallItem(OpenhabItem): """CallItem which accepts and converts the data types from OpenHAB :ivar str name: |oh_item_desc_name| - :ivar Tuple[str, ...] value: |oh_item_desc_value| + :ivar tuple[str, ...] value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ @@ -43,11 +41,11 @@ class LocationItem(OpenhabItem): """LocationItem which accepts and converts the data types from OpenHAB :ivar str name: |oh_item_desc_name| - :ivar Optional[Tuple[float, float, Optional[float]]] value: |oh_item_desc_value| + :ivar tuple[float, float, float | None] | None value: |oh_item_desc_value| - :ivar Optional[str] label: |oh_item_desc_label| - :ivar FrozenSet[str] tags: |oh_item_desc_tags| - :ivar FrozenSet[str] groups: |oh_item_desc_group| + :ivar str | None label: |oh_item_desc_label| + :ivar frozenset[str] tags: |oh_item_desc_tags| + :ivar frozenset[str] groups: |oh_item_desc_group| :ivar Mapping[str, MetaData] metadata: |oh_item_desc_metadata| """ diff --git a/src/HABApp/openhab/map_events.py b/src/HABApp/openhab/map_events.py index b6023393..844bed0e 100644 --- a/src/HABApp/openhab/map_events.py +++ b/src/HABApp/openhab/map_events.py @@ -1,5 +1,3 @@ -from typing import Dict, Type - from HABApp.core.const.json import load_json from .events import ( @@ -43,7 +41,7 @@ ThingConfigStatusInfoEvent, ] -_events: Dict[str, Type[OpenhabEvent]] = {k.__name__: k for k in EVENT_LIST} +_events: dict[str, type[OpenhabEvent]] = {k.__name__: k for k in EVENT_LIST} _events['GroupItemStateChangedEvent'] = GroupStateChangedEvent # Naming from openHAB is inconsistent here _events['FirmwareStatusInfoEvent'] = ThingFirmwareStatusInfoEvent # Naming from openHAB is inconsistent here _events['ConfigStatusInfoEvent'] = ThingConfigStatusInfoEvent # Naming from openHAB is inconsistent here diff --git a/src/HABApp/openhab/map_items.py b/src/HABApp/openhab/map_items.py index 9ec7b45d..227888a2 100644 --- a/src/HABApp/openhab/map_items.py +++ b/src/HABApp/openhab/map_items.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, FrozenSet, Optional +from typing import Any from immutables import Map @@ -26,7 +26,7 @@ log = logging.getLogger('HABApp.openhab.items') -_items: Dict[str, HINT_TYPE_OPENHAB_ITEM] = { +_items: dict[str, HINT_TYPE_OPENHAB_ITEM] = { 'String': StringItem, 'Number': NumberItem, 'Switch': SwitchItem, @@ -43,10 +43,10 @@ } -def map_item(name: str, type: str, value: Optional[str], - label: Optional[str], tags: FrozenSet[str], - groups: FrozenSet[str], metadata: Optional[Dict[str, Dict[str, Any]]]) -> \ - Optional[OpenhabItem]: +def map_item(name: str, type: str, value: str | None, + label: str | None, tags: frozenset[str], + groups: frozenset[str], metadata: dict[str, dict[str, Any]] | None) -> \ + OpenhabItem | None: try: assert isinstance(type, str) assert value is None or isinstance(value, str) @@ -75,8 +75,8 @@ def map_item(name: str, type: str, value: Optional[str], return cls.from_oh(name, value, label=label, tags=tags, groups=groups, metadata=meta) msg = f'Unknown openHAB type: {type} for {name}' - raise ValueError(msg) + raise ValueError(msg) # noqa: TRY301 except Exception as e: - process_exception('map_items', e, logger=log) + process_exception(map_item, e, logger=log) return None diff --git a/src/HABApp/openhab/process_events.py b/src/HABApp/openhab/process_events.py index dbc4532c..856aa04d 100644 --- a/src/HABApp/openhab/process_events.py +++ b/src/HABApp/openhab/process_events.py @@ -1,5 +1,4 @@ import logging -from typing import Union import HABApp import HABApp.core @@ -106,7 +105,7 @@ def on_sse_event(event_dict: dict, oh_3: bool): return None -async def item_event(event: Union[ItemAddedEvent, ItemUpdatedEvent]): +async def item_event(event: ItemAddedEvent | ItemUpdatedEvent): try: from HABApp.openhab.map_items import map_item diff --git a/src/HABApp/openhab/transformations/_map/classes.py b/src/HABApp/openhab/transformations/_map/classes.py index 237c32a4..31059f21 100644 --- a/src/HABApp/openhab/transformations/_map/classes.py +++ b/src/HABApp/openhab/transformations/_map/classes.py @@ -8,7 +8,7 @@ def __init__(self, *args, name: str) -> None: super().__init__(*args) self._name = name - def __repr__(self, additional: str = ''): + def __repr__(self, additional: str = '') -> str: return f'<{self.__class__.__name__} name={self._name} items={super().__repr__()}{additional:s}>' @@ -20,7 +20,7 @@ def __init__(self, *args, default, **kwargs) -> None: def __missing__(self, key): return self._default - def __repr__(self, additional: str = ''): + def __repr__(self, additional: str = '') -> str: return super().__repr__(f', default={self._default}{additional}') def get(self, key, default=None) -> NoReturn: diff --git a/src/HABApp/openhab/transformations/_map/registry.py b/src/HABApp/openhab/transformations/_map/registry.py index 7bef0568..167d7074 100644 --- a/src/HABApp/openhab/transformations/_map/registry.py +++ b/src/HABApp/openhab/transformations/_map/registry.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Final, Tuple, Union +from typing import Any, Final from javaproperties import loads as load_map_file @@ -9,11 +9,11 @@ class MapTransformationRegistry(TransformationRegistryBase): - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__(name) - self.objs: Dict[str, Tuple[dict, Any]] = {} + self.objs: dict[str, tuple[dict, Any]] = {} - def get(self, name: str) -> Union[MapTransformation, MapTransformationWithDefault]: + def get(self, name: str) -> MapTransformation | MapTransformationWithDefault: try: data, default = self.objs[name] except KeyError: @@ -24,7 +24,7 @@ def get(self, name: str) -> Union[MapTransformation, MapTransformationWithDefaul else: return MapTransformation(data, name=name) - def set(self, name: str, configuration: Dict[str, str]): + def set(self, name: str, configuration: dict[str, str]): data = load_map_file(configuration['function']) if not data: log.warning(f'Map transformation "{name:s}" is empty -> skipped!') @@ -48,7 +48,7 @@ def set(self, name: str, configuration: Dict[str, str]): MAP_REGISTRY: Final = MapTransformationRegistry('map') -class MapTransformationFactory(TransformationFactoryBase[Dict[Union[str, int], Union[str, int]]]): +class MapTransformationFactory(TransformationFactoryBase[dict[str | int, str | int]]): pass diff --git a/src/HABApp/openhab/transformations/base.py b/src/HABApp/openhab/transformations/base.py index 9b89c7ac..29109db4 100644 --- a/src/HABApp/openhab/transformations/base.py +++ b/src/HABApp/openhab/transformations/base.py @@ -1,5 +1,5 @@ import logging -from typing import Any, Dict, Final, Generic, Tuple, TypeVar +from typing import Any, Final, Generic, TypeVar T = TypeVar('T') @@ -8,10 +8,10 @@ class TransformationFactoryBase(Generic[T]): - def __init__(self, registry: 'TransformationRegistryBase'): + def __init__(self, registry: 'TransformationRegistryBase') -> None: self._registry: Final = registry - def __repr__(self): + def __repr__(self) -> str: return f'<{self._registry.name.title()}{self.__class__.__name__}>' def __getitem__(self, key: str) -> T: @@ -29,15 +29,15 @@ def sort_order(uid: str): class TransformationRegistryBase: - objs: Dict[str, Any] + objs: dict[str, Any] - def __init__(self, name: str): + def __init__(self, name: str) -> None: self.name: Final = name - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {" ".join(self.available())}' - def available(self) -> Tuple[str, ...]: + def available(self) -> tuple[str, ...]: return tuple(sorted(self.objs.keys(), key=sort_order)) def get(self, name: str): @@ -46,5 +46,5 @@ def get(self, name: str): def set(self, name: str, configuration: dict): raise NotImplementedError() - def clear(self): + def clear(self) -> None: self.objs.clear() diff --git a/src/HABApp/parameters/parameter.py b/src/HABApp/parameters/parameter.py index 5879b1a0..72f92150 100644 --- a/src/HABApp/parameters/parameter.py +++ b/src/HABApp/parameters/parameter.py @@ -10,7 +10,7 @@ class BaseParameter: - def __init__(self, filename: str, *keys, default_value: typing.Any = 'ToDo'): + def __init__(self, filename: str, *keys, default_value: typing.Any = 'ToDo') -> None: """Class to dynamically access parameters which are loaded from file. :param filename: filename (without extension) @@ -37,10 +37,10 @@ def value(self) -> typing.Any: """ return _get_value(self._filename, *self._keys) - def __repr__(self): + def __repr__(self) -> str: return f' bool: return bool(self.value) def __eq__(self, other): @@ -116,13 +116,13 @@ def __invert__(self): return self.value.__invert__() # built-in functions complex(), int() and float(). - def __complex__(self): + def __complex__(self) -> complex: return self.value.__complex__() - def __int__(self): + def __int__(self) -> int: return self.value.__int__() - def __float__(self): + def __float__(self) -> float: return self.value.__float__() # built-in function round() and math functions trunc(), floor() and ceil(). @@ -193,10 +193,10 @@ def value(self) -> dict: raise ValueError(f'Value "{value}" for {self.__class__.__name__} is not a dict! ({type(value)})') return value - def __repr__(self): + def __repr__(self) -> str: return f' bool: return bool(self.value) def __eq__(self, other): @@ -205,13 +205,13 @@ def __eq__(self, other): def __getitem__(self, item): return self.value[item] - def __contains__(self, key): + def __contains__(self, key) -> bool: return key in self.value def __iter__(self): return iter(self.value) - def __len__(self): + def __len__(self) -> int: return len(self.value) def keys(self): @@ -226,8 +226,8 @@ def items(self): def get(self, item, default=None): return self.value.get(item, default) - def __setitem__(self, key, value): + def __setitem__(self, key, value) -> None: raise PermissionError('Parameter can not be changed!') - def __delitem__(self, key): + def __delitem__(self, key) -> None: raise PermissionError('Parameter can not be changed!') diff --git a/src/HABApp/parameters/parameter_files.py b/src/HABApp/parameters/parameter_files.py index a8d06452..023280d9 100644 --- a/src/HABApp/parameters/parameter_files.py +++ b/src/HABApp/parameters/parameter_files.py @@ -15,7 +15,7 @@ PARAM_PREFIX = 'params/' -async def load_file(name: str, path: Path): +async def load_file(name: str, path: Path) -> None: with LOCK: # serialize to get proper error messages with path.open(mode='r', encoding='utf-8') as file: data = HABApp.core.const.yml.load(file) @@ -26,7 +26,7 @@ async def load_file(name: str, path: Path): log.debug(f'Loaded params from {name}!') -async def unload_file(name: str, path: Path): +async def unload_file(name: str, path: Path) -> None: with LOCK: # serialize to get proper error messages remove_parameter_file(path.stem) @@ -66,7 +66,7 @@ async def setup_param_files() -> bool: return True -def reload_param_file(name: str): +def reload_param_file(name: str) -> None: name = f'{PARAM_PREFIX}{name}.yml' path = HABApp.core.files.folders.get_path(name) HABApp.core.asyncio.create_task(HABApp.core.files.manager.process_file(name, path)) diff --git a/src/HABApp/parameters/parameters.py b/src/HABApp/parameters/parameters.py index 6f32e3eb..353643b7 100644 --- a/src/HABApp/parameters/parameters.py +++ b/src/HABApp/parameters/parameters.py @@ -11,7 +11,7 @@ def remove_parameter_file(file): return _PARAMETERS.pop(file) -def set_parameter_file(file: str, value): +def set_parameter_file(file: str, value) -> None: # validate the parameters, this will raise an exception validator = _VALIDATORS.get(file) if validator is not None: @@ -24,7 +24,7 @@ def get_parameter_file(file: str): return _PARAMETERS[file] -def set_file_validator(filename: str, validator: typing.Any, allow_extra_keys=True): +def set_file_validator(filename: str, validator: typing.Any, allow_extra_keys=True) -> None: """Add a validator for the parameter file. If the file is already loaded this will reload the file. :param filename: filename which shall be validated (without extension) diff --git a/src/HABApp/rule/__init__.py b/src/HABApp/rule/__init__.py index daba1f9b..53b92c61 100644 --- a/src/HABApp/rule/__init__.py +++ b/src/HABApp/rule/__init__.py @@ -3,3 +3,4 @@ # isort: split from .rule import Rule, create_rule +from . import scheduler diff --git a/src/HABApp/rule/interfaces/__init__.py b/src/HABApp/rule/interfaces/__init__.py index 82c3f30d..c833d0df 100644 --- a/src/HABApp/rule/interfaces/__init__.py +++ b/src/HABApp/rule/interfaces/__init__.py @@ -1,2 +1,2 @@ -from HABApp.rule.interfaces import http_interface as http +from HABApp.rule.interfaces import interface_http as http from .rule_subprocess import async_subprocess_exec, FinishedProcessInfo diff --git a/src/HABApp/rule/interfaces/_http.py b/src/HABApp/rule/interfaces/_http.py index 8c53285e..8b2e33fa 100644 --- a/src/HABApp/rule/interfaces/_http.py +++ b/src/HABApp/rule/interfaces/_http.py @@ -1,25 +1,26 @@ -from typing import Any, Mapping, Optional +from collections.abc import Mapping +from typing import Any import aiohttp import HABApp +from HABApp.core import shutdown from HABApp.core.const.json import dump_json -CLIENT: Optional[aiohttp.ClientSession] = None +CLIENT: aiohttp.ClientSession | None = None -async def create_client(): +async def create_client() -> None: global CLIENT assert CLIENT is None CLIENT = aiohttp.ClientSession(json_serialize=dump_json, loop=HABApp.core.const.loop) - from HABApp.runtime import shutdown - shutdown.register_func(CLIENT.close, msg='Closing generic http connection') + shutdown.register(CLIENT.close, msg='Closing generic http connection') -def get(url: str, params: Optional[Mapping[str, str]] = None, **kwargs: Any)\ +def get(url: str, params: Mapping[str, str] | None = None, **kwargs: Any)\ -> aiohttp.client._RequestContextManager: """http get request @@ -36,7 +37,7 @@ def get(url: str, params: Optional[Mapping[str, str]] = None, **kwargs: Any)\ return CLIENT.get(url, params=params, **kwargs) -def post(url: str, params: Optional[Mapping[str, str]] = None, +def post(url: str, params: Mapping[str, str] | None = None, data: Any = None, json: Any = None, **kwargs: Any) -> aiohttp.client._RequestContextManager: """http post request @@ -57,7 +58,7 @@ def post(url: str, params: Optional[Mapping[str, str]] = None, return CLIENT.post(url, params=params, data=data, json=json, **kwargs) -def put(url: str, params: Optional[Mapping[str, str]] = None, +def put(url: str, params: Mapping[str, str] | None = None, data: Any = None, json: Any = None, **kwargs: Any) -> aiohttp.client._RequestContextManager: """http put request @@ -78,7 +79,7 @@ def put(url: str, params: Optional[Mapping[str, str]] = None, return CLIENT.put(url, params=params, data=data, json=json, **kwargs) -def delete(url: str, params: Optional[Mapping[str, str]] = None, **kwargs: Any)\ +def delete(url: str, params: Mapping[str, str] | None = None, **kwargs: Any)\ -> aiohttp.client._RequestContextManager: """http delete request diff --git a/src/HABApp/rule/interfaces/http_interface.py b/src/HABApp/rule/interfaces/interface_http.py similarity index 100% rename from src/HABApp/rule/interfaces/http_interface.py rename to src/HABApp/rule/interfaces/interface_http.py diff --git a/src/HABApp/rule/interfaces/rule_subprocess.py b/src/HABApp/rule/interfaces/rule_subprocess.py index 4d04f5a0..ab1b7a9e 100644 --- a/src/HABApp/rule/interfaces/rule_subprocess.py +++ b/src/HABApp/rule/interfaces/rule_subprocess.py @@ -1,10 +1,9 @@ import asyncio import logging import os +from collections.abc import Callable, Iterable from pathlib import Path -from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple, Union - -from typing_extensions import TypeAlias +from typing import Any, TypeAlias import HABApp from HABApp.core.logger import HABAppError, HABAppWarning @@ -13,12 +12,12 @@ log = logging.getLogger('HABApp.execute') -HINT_EXEC_ARGS: TypeAlias = Union[str, Path] -HINT_PYTHON_PATH: TypeAlias = Optional[Iterable[Union[str, Path]]] +HINT_EXEC_ARGS: TypeAlias = str | Path +HINT_PYTHON_PATH: TypeAlias = Iterable[str | Path] | None -def _ensure_str_objs(objs: Iterable[HINT_EXEC_ARGS], key: str, enforce_abs=False) -> List[str]: - new_args: List[str] = [] +def _ensure_str_objs(objs: Iterable[HINT_EXEC_ARGS], key: str, enforce_abs=False) -> list[str]: + new_args: list[str] = [] # args must be str, but we support str and Path for i, val in enumerate(objs): @@ -42,9 +41,9 @@ def _ensure_str_objs(objs: Iterable[HINT_EXEC_ARGS], key: str, enforce_abs=False def build_exec_params(*args: HINT_EXEC_ARGS, - _capture_output=True, + _capture_output: bool = True, _additional_python_path: HINT_PYTHON_PATH = None, - **kwargs: Any) -> Tuple[Iterable[str], Dict[str, Any]]: + **kwargs: Any) -> tuple[Iterable[str], dict[str, Any]]: # convenience for easy capturing if _capture_output: if 'stdout' in kwargs: @@ -89,12 +88,12 @@ class FinishedProcessInfo: :var Optional[str] stderr: Error output of the process or ``None`` """ - def __init__(self, returncode: int, stdout: Optional[str], stderr: Optional[str]): + def __init__(self, returncode: int, stdout: str | None, stderr: str | None) -> None: self.returncode: int = returncode - self.stdout: Optional[str] = stdout - self.stderr: Optional[str] = stderr + self.stdout: str | None = stdout + self.stderr: str | None = stderr - def __repr__(self): + def __repr__(self) -> str: return f'' def __eq__(self, other): diff --git a/src/HABApp/rule/rule.py b/src/HABApp/rule/rule.py index faa1dd3f..464fa723 100644 --- a/src/HABApp/rule/rule.py +++ b/src/HABApp/rule/rule.py @@ -2,8 +2,10 @@ import re import sys import warnings +from collections.abc import Callable, Iterable from pathlib import Path -from typing import Any, Callable, Final, Iterable, List, Literal, Optional, Pattern, Tuple, TypeVar, Union, overload +from re import Pattern +from typing import Any, Final, Literal, ParamSpec, TypeVar, overload import HABApp import HABApp.core @@ -11,21 +13,19 @@ import HABApp.rule_manager import HABApp.util from HABApp.core.asyncio import create_task -from HABApp.core.const.const import PYTHON_310 from HABApp.core.const.hints import TYPE_EVENT_CALLBACK from HABApp.core.internals import ( - HINT_EVENT_BUS_LISTENER, - HINT_EVENT_FILTER_OBJ, ContextBoundEventBusListener, ContextProvidingObj, + EventBusListener, EventFilterBase, uses_item_registry, uses_post_event, wrap_func, ) -from HABApp.core.items import HINT_ITEM_OBJ, HINT_TYPE_ITEM_OBJ, BaseItem, BaseValueItem +from HABApp.core.items import BaseItem, BaseValueItem from HABApp.rule import interfaces -from HABApp.rule.scheduler import HABAppSchedulerView as _HABAppSchedulerView +from HABApp.rule.scheduler.job_builder import HABAppJobBuilder as _HABAppJobBuilder from .interfaces import async_subprocess_exec from .interfaces.rule_subprocess import ( @@ -38,17 +38,11 @@ from .rule_hook import get_rule_hook as _get_rule_hook -if PYTHON_310: - from typing import ParamSpec -else: - from typing_extensions import ParamSpec - - log = logging.getLogger('HABApp.Rule') # Func to log deprecation warnings -def send_warnings_to_log(message, category, filename, lineno, file=None, line=None): +def send_warnings_to_log(message, category, filename, lineno, file=None, line=None) -> None: log.warning(f'{filename}:{lineno}: {category.__name__}:{message}') return @@ -62,9 +56,12 @@ def send_warnings_to_log(message, category, filename, lineno, file=None, line=No item_registry = uses_item_registry() +ITEM_TYPE = TypeVar('ITEM_TYPE', bound=BaseItem) + + class Rule(ContextProvidingObj): - def __init__(self): + def __init__(self) -> None: super().__init__(context=HABApp.rule_ctx.HABAppRuleContext(self)) hook = _get_rule_hook() @@ -74,7 +71,7 @@ def __init__(self): assert isinstance(self.__runtime, HABApp.runtime.Runtime) # scheduler - self.run: _HABAppSchedulerView = _HABAppSchedulerView(self._habapp_ctx) + self.run: Final = _HABAppJobBuilder(self._habapp_ctx) # suggest a rule name self.rule_name: str = hook.suggest_rule_name(self) @@ -85,15 +82,15 @@ def __init__(self): self.oh: Final = HABApp.openhab.interface_sync self.openhab: Final = self.oh - def on_rule_loaded(self): + def on_rule_loaded(self) -> None: """Override this to implement logic that will be called when the rule and the file has been successfully loaded """ - def on_rule_removed(self): + def on_rule_removed(self) -> None: """Override this to implement logic that will be called when the rule has been unloaded. """ - def __repr__(self): + def __repr__(self) -> str: # empty string, so we have a space if we have more than one entry parts = [''] @@ -109,7 +106,7 @@ def __repr__(self): return f'<{cls_name}{" ".join(parts)}>' - def post_event(self, name: Union[HINT_ITEM_OBJ, str], event: Any): + def post_event(self, name: BaseItem | str, event: Any) -> None: """ Post an event to the event bus @@ -123,10 +120,10 @@ def post_event(self, name: Union[HINT_ITEM_OBJ, str], event: Any): event ) - def listen_event(self, name: Union[HINT_ITEM_OBJ, str], + def listen_event(self, name: BaseItem | str, callback: TYPE_EVENT_CALLBACK, - event_filter: Optional[HINT_EVENT_FILTER_OBJ] = None - ) -> HINT_EVENT_BUS_LISTENER: + event_filter: EventFilterBase | None = None + ) -> EventBusListener: """ Register an event listener @@ -144,7 +141,8 @@ def listen_event(self, name: Union[HINT_ITEM_OBJ, str], if event_filter is None: event_filter = HABApp.core.events.NoEventFilter() if not isinstance(event_filter, EventFilterBase): - raise ValueError(f'Argument event_filter must be an instance of event filter (is {event_filter})') + msg = f'Argument event_filter must be an instance of event filter (is {event_filter})' + raise TypeError(msg) listener = ContextBoundEventBusListener(name, cb, event_filter, parent_ctx=self._habapp_ctx) return self._habapp_ctx.add_event_listener(listener) @@ -152,13 +150,13 @@ def listen_event(self, name: Union[HINT_ITEM_OBJ, str], @overload def execute_subprocess(self, callback: HINT_PROCESS_CB_SIMPLE, program: HINT_EXEC_ARGS, *args: HINT_EXEC_ARGS, additional_python_path: HINT_PYTHON_PATH = None, capture_output: bool = True, - raw_info: Literal[False], **kwargs): + raw_info: Literal[False], **kwargs) -> None: ... @overload def execute_subprocess(self, callback: HINT_PROCESS_CB_FULL, program: HINT_EXEC_ARGS, *args: HINT_EXEC_ARGS, additional_python_path: HINT_PYTHON_PATH = None, capture_output: bool = True, - raw_info: Literal[True], **kwargs): + raw_info: Literal[True], **kwargs) -> None: ... def execute_subprocess(self, callback, program: HINT_EXEC_ARGS, *args: HINT_EXEC_ARGS, @@ -196,13 +194,13 @@ def execute_subprocess(self, callback, program: HINT_EXEC_ARGS, *args: HINT_EXEC @overload def execute_python(self, callback: HINT_PROCESS_CB_SIMPLE, module_or_package: HINT_EXEC_ARGS, *args: HINT_EXEC_ARGS, additional_python_path: HINT_PYTHON_PATH = None, capture_output: bool = True, - raw_info: Literal[False], **kwargs): + raw_info: Literal[False], **kwargs) -> None: ... @overload def execute_python(self, callback: HINT_PROCESS_CB_FULL, module_or_package: HINT_EXEC_ARGS, *args: HINT_EXEC_ARGS, additional_python_path: HINT_PYTHON_PATH = None, capture_output: bool = True, - raw_info: Literal[True], **kwargs): + raw_info: Literal[True], **kwargs) -> None: ... def execute_python(self, callback, module_or_package: HINT_EXEC_ARGS, *args: HINT_EXEC_ARGS, @@ -257,18 +255,18 @@ def execute_python(self, callback, module_or_package: HINT_EXEC_ARGS, *args: HIN cb.run, *call_args, raw_info=raw_info, calling_func=self.execute_python, **call_kwargs) ) - def get_rule(self, rule_name: str) -> 'Union[Rule, List[Rule]]': + def get_rule(self, rule_name: str) -> 'Rule | list[Rule]': assert rule_name is None or isinstance(rule_name, str), type(rule_name) return self.__runtime.rule_manager.get_rule(rule_name) @staticmethod - def get_items(type: Union[Tuple[HINT_TYPE_ITEM_OBJ, ...], HINT_TYPE_ITEM_OBJ] = None, - name: Union[str, Pattern[str], None] = None, - tags: Union[str, Iterable[str], None] = None, - groups: Union[str, Iterable[str], None] = None, - metadata: Union[str, Pattern[str], None] = None, - metadata_value: Union[str, Pattern[str], None] = None, - ) -> Union[List[HINT_ITEM_OBJ], List[BaseItem]]: + def get_items(type: tuple[type[ITEM_TYPE], ...] | type[ITEM_TYPE] | None = None, + name: str | Pattern[str] | None = None, + tags: str | Iterable[str] | None = None, + groups: str | Iterable[str] | None = None, + metadata: str | Pattern[str] | None = None, + metadata_value: str | Pattern[str] | None = None, + ) -> list[ITEM_TYPE] | list[BaseItem]: """Search the HABApp item registry and return the found items. :param type: item has to be an instance of this class @@ -299,7 +297,8 @@ def get_items(type: Union[Tuple[HINT_TYPE_ITEM_OBJ, ...], HINT_TYPE_ITEM_OBJ] = if type is None: type = OpenhabItem if not issubclass(type, OpenhabItem): - raise ValueError('Searching for tags, groups and metadata only works for OpenhabItem or its subclasses') + msg = 'Searching for tags, groups and metadata only works for OpenhabItem or its subclasses' + raise ValueError(msg) ret = [] for item in item_registry.get_items(): # type: HABApp.core.items.BaseItem diff --git a/src/HABApp/rule/rule_hook.py b/src/HABApp/rule/rule_hook.py index bda9759a..dbacac22 100644 --- a/src/HABApp/rule/rule_hook.py +++ b/src/HABApp/rule/rule_hook.py @@ -1,9 +1,10 @@ import logging +from collections.abc import Callable # noinspection PyProtectedMember from sys import _getframe as sys_get_frame -from types import FrameType -from typing import TYPE_CHECKING, Any, Callable, Final, Optional +from types import FrameType, TracebackType +from typing import TYPE_CHECKING, Any, Final if TYPE_CHECKING: @@ -21,7 +22,7 @@ class HABAppRuleHook: def __init__(self, cb_register_rule: Callable[['HABApp.rule.Rule'], Any], cb_suggest_name: Callable[['HABApp.rule.Rule'], str], - runtime: 'HABApp.runtime.Runtime', rule_file: 'HABApp.rule_manager.RuleFile'): + runtime: 'HABApp.runtime.Runtime', rule_file: 'HABApp.rule_manager.RuleFile') -> None: # callbacks self._cb_register: Final = cb_register_rule self._cb_suggest_name: Final = cb_suggest_name @@ -32,10 +33,10 @@ def __init__(self, self.closed = False - def __enter__(self): + def __enter__(self) -> None: pass - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> None: self.closed = True def register_rule(self, rule: 'HABApp.rule.Rule'): @@ -48,7 +49,7 @@ def register_rule(self, rule: 'HABApp.rule.Rule'): def suggest_rule_name(self, rule: 'HABApp.rule.Rule') -> str: return self._cb_suggest_name(rule) - def in_dict(self, obj: Optional[dict] = None) -> dict: + def in_dict(self, obj: dict | None = None) -> dict: if obj is None: obj = {} obj[_NAME] = self @@ -58,7 +59,7 @@ def in_dict(self, obj: Optional[dict] = None) -> dict: def get_rule_hook() -> HABAppRuleHook: # noinspection PyUnresolvedReferences - frame: Optional[FrameType] = sys_get_frame(1) + frame: FrameType | None = sys_get_frame(1) while frame is not None: _globals = frame.f_globals @@ -69,5 +70,8 @@ def get_rule_hook() -> HABAppRuleHook: frame = frame.f_back - raise RuntimeError('HABApp rule files are not meant to be executed directly! ' - 'Put the file in the HABApp "rule" folder and HABApp will load it automatically.') + msg = ( + 'HABApp rule files are not meant to be executed directly! ' + 'Put the file in the HABApp "rule" folder and HABApp will load it automatically.' + ) + raise RuntimeError(msg) diff --git a/src/HABApp/rule/scheduler/__init__.py b/src/HABApp/rule/scheduler/__init__.py index e5c1039c..5d4c88d4 100644 --- a/src/HABApp/rule/scheduler/__init__.py +++ b/src/HABApp/rule/scheduler/__init__.py @@ -1 +1,6 @@ -from .habappschedulerview import HABAppSchedulerView +from eascheduler import add_holiday, get_holiday_name, get_holidays_by_name, get_sun_position, is_holiday, pop_holiday +from eascheduler.builder import FilterBuilder as filter +from eascheduler.builder import TriggerBuilder as trigger +from whenever import hours, milliseconds, minutes, seconds + +from HABApp.core.lib import InstantView diff --git a/src/HABApp/rule/scheduler/executor.py b/src/HABApp/rule/scheduler/executor.py deleted file mode 100644 index 3ca07f72..00000000 --- a/src/HABApp/rule/scheduler/executor.py +++ /dev/null @@ -1,14 +0,0 @@ -from typing import Callable - -from eascheduler.executors import ExecutorBase - -from HABApp.core.internals.wrapped_function import WrappedFunctionBase - - -class WrappedFunctionExecutor(ExecutorBase): - def __init__(self, func: Callable, *args, **kwargs): - assert isinstance(func, WrappedFunctionBase), type(func) - super().__init__(func, *args, **kwargs) - - def execute(self): - self._func.run(*self._args, **self._kwargs) diff --git a/src/HABApp/rule/scheduler/habappschedulerview.py b/src/HABApp/rule/scheduler/habappschedulerview.py deleted file mode 100644 index eb7aa5a6..00000000 --- a/src/HABApp/rule/scheduler/habappschedulerview.py +++ /dev/null @@ -1,113 +0,0 @@ -import random -from datetime import datetime as dt_datetime -from datetime import time as dt_time -from datetime import timedelta as dt_timedelta -from typing import Any, Callable, Iterable, Union - -from eascheduler import SchedulerView - -import HABApp.rule_ctx -from HABApp.core.const.const import PYTHON_310 -from HABApp.core.internals import Context, ContextProvidingObj, wrap_func -from HABApp.rule.scheduler.executor import WrappedFunctionExecutor -from HABApp.rule.scheduler.scheduler import HABAppScheduler as _HABAppScheduler - -from .jobs import CountdownJob, DawnJob, DayOfWeekJob, DuskJob, OneTimeJob, ReoccurringJob, SunriseJob, SunsetJob - - -if PYTHON_310: - from typing import ParamSpec, TypeAlias -else: - from typing_extensions import ParamSpec, TypeAlias - - -HINT_CB_P = ParamSpec('HINT_CB_P') -HINT_CB: TypeAlias = Callable[HINT_CB_P, Any] - - - -class HABAppSchedulerView(SchedulerView, ContextProvidingObj): - def __init__(self, context: 'HABApp.rule_ctx.HABAppRuleContext'): - super().__init__(_HABAppScheduler(), WrappedFunctionExecutor) - self._habapp_rule_ctx: Context = context - - def at(self, time: Union[None, dt_datetime, dt_timedelta, dt_time, int], - callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> OneTimeJob: - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().at(time, callback, *args, **kwargs) - - def countdown(self, expire_time: Union[dt_timedelta, float, int], - callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> CountdownJob: - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().countdown(expire_time, callback, *args, **kwargs) - - def every(self, start_time: Union[None, dt_datetime, dt_timedelta, dt_time, int], - interval: Union[int, float, dt_timedelta], - callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> ReoccurringJob: - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().every(start_time, interval, callback, *args, **kwargs) - - def on_day_of_week(self, time: Union[dt_time, dt_datetime], weekdays: Union[str, Iterable[Union[str, int]]], - callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DayOfWeekJob: - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().on_day_of_week(time, weekdays, callback, *args, **kwargs) - - def on_every_day(self, time: Union[dt_time, dt_datetime], - callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DayOfWeekJob: - """Create a job that will run at a certain time of day - - :param time: Time when the job will run - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().on_day_of_week(time, 'all', callback, *args, **kwargs) - - def on_sunrise(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> SunriseJob: - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().on_sunrise(callback, *args, **kwargs) - - def on_sunset(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> SunsetJob: - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().on_sunset(callback, *args, **kwargs) - - def on_sun_dawn(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DawnJob: - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().on_sun_dawn(callback, *args, **kwargs) - - def on_sun_dusk(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DuskJob: - callback = wrap_func(callback, context=self._habapp_rule_ctx) - return super().on_sun_dusk(callback, *args, **kwargs) - - def soon(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> OneTimeJob: - """ - Run the callback as soon as possible. - - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - return self.at(None, callback, *args, **kwargs) - - def every_minute(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> ReoccurringJob: - """Picks a random second and runs the callback every minute - - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - start = dt_timedelta(seconds=random.randint(0, 60 - 1)) - interval = dt_timedelta(seconds=60) - return self.every(start, interval, callback, *args, **kwargs) - - def every_hour(self, callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> ReoccurringJob: - """Picks a random minute and second and run the callback every hour - - :param callback: |param_scheduled_cb| - :param args: |param_scheduled_cb_args| - :param kwargs: |param_scheduled_cb_kwargs| - """ - start = dt_timedelta(seconds=random.randint(0, 3600 - 1)) - interval = dt_timedelta(hours=1) - return self.every(start, interval, callback, *args, **kwargs) diff --git a/src/HABApp/rule/scheduler/job_builder.py b/src/HABApp/rule/scheduler/job_builder.py new file mode 100644 index 00000000..a1ea7932 --- /dev/null +++ b/src/HABApp/rule/scheduler/job_builder.py @@ -0,0 +1,262 @@ +from __future__ import annotations + +import random +import warnings +from collections.abc import Callable, Hashable, Iterable, Mapping +from datetime import datetime as dt_datetime +from typing import TYPE_CHECKING, Any, Final, TypeAlias + +from eascheduler.builder import FilterBuilder, JobBuilder, TriggerBuilder +from eascheduler.builder.helper import HINT_INSTANT, HINT_TIMEDELTA, get_instant, get_pos_timedelta_secs +from eascheduler.builder.triggers import TriggerObject, _get_producer +from eascheduler.executor import ExecutorBase +from eascheduler.jobs import CountdownJob, DateTimeJob, OneTimeJob +from eascheduler.schedulers.async_scheduler import AsyncScheduler +from typing_extensions import ParamSpec, Self, override + +from HABApp.core.asyncio import async_context, create_task_from_async, run_func_from_async +from HABApp.core.const import loop +from HABApp.core.internals import Context, wrap_func +from HABApp.core.internals.wrapped_function.wrapped_async import WrappedAsyncFunction +from HABApp.rule.scheduler.job_ctrl import CountdownJobControl, DateTimeJobControl, OneTimeJobControl + + +if TYPE_CHECKING: + + from HABApp.core.internals.wrapped_function.wrapped_thread import WrappedThreadFunction + from HABApp.rule_ctx import HABAppRuleContext + + +HINT_CB_P = ParamSpec('HINT_CB_P') +HINT_CB: TypeAlias = Callable[HINT_CB_P, Any] + + +class WrappedSyncExecutor(ExecutorBase): + def __init__(self, func: WrappedThreadFunction, + args: Iterable = (), kwargs: Mapping[str, Any] | None = None) -> None: + self._func: Final = func + self._args: Final = args + self._kwargs: Final = kwargs if kwargs is not None else {} + + @override + def execute(self) -> None: + self._func.run(*self._args, **self._kwargs) + + +class WrappedAsyncExecutor(ExecutorBase): + def __init__(self, func: WrappedAsyncFunction, + args: Iterable = (), kwargs: Mapping[str, Any] | None = None) -> None: + self._func: Final = func + self._args: Final = args + self._kwargs: Final = kwargs if kwargs is not None else {} + + @override + def execute(self) -> None: + create_task_from_async( + self._func.async_run(*self._args, **self._kwargs), + name=self._func.name + ) + + +def wrapped_func_executor(func: Any, args: Iterable = (), kwargs: Mapping[str, Any] | None = None) -> ExecutorBase: + if isinstance(func, WrappedAsyncFunction): + return WrappedAsyncExecutor(func, args, kwargs) + return WrappedSyncExecutor(func, args, kwargs) + + +class AsyncHABAppScheduler(AsyncScheduler): + + @override + def run_jobs(self) -> None: + ctx = async_context.set('Scheduler') + try: + super().run_jobs() + finally: + async_context.reset(ctx) + + @override + def set_enabled(self, enabled: bool) -> Self: # noqa: FBT001 + return run_func_from_async(super().set_enabled, enabled) + + +class HABAppJobBuilder: + def __init__(self, context: HABAppRuleContext) -> None: + self._habapp_rule_ctx: Context = context + self._scheduler: Final = AsyncHABAppScheduler(event_loop=loop, enabled=False) + + self._builder: Final = JobBuilder(self._scheduler, wrapped_func_executor) + + self.trigger: Final = TriggerBuilder + self.filter: Final = FilterBuilder + + def countdown(self, secs: HINT_TIMEDELTA, callback: HINT_CB, + *args: HINT_CB_P.args, + job_id: Hashable | None = None, **kwargs: HINT_CB_P.kwargs) -> CountdownJobControl: + """Create a job that count town a certain time and then execute. + + :param secs: countdown time in seconds + :param callback: |param_scheduled_cb| + :param args: |param_scheduled_cb_args| + :param job_id: + :param kwargs: |param_scheduled_cb_kwargs| + :return: Created job + """ + callback = wrap_func(callback, context=self._habapp_rule_ctx) + + job = CountdownJob(wrapped_func_executor(callback, args, kwargs), get_pos_timedelta_secs(secs), job_id=job_id) + run_func_from_async(job.link_scheduler, self._scheduler) + return CountdownJobControl(job) + + def once(self, instant: HINT_INSTANT, callback: HINT_CB, + *args: HINT_CB_P.args, + job_id: Hashable | None = None, **kwargs: HINT_CB_P.kwargs) -> OneTimeJobControl: + """Create a job that runs once. + + :param instant: countdown time in seconds + :param coro_func: |param_scheduled_cb| + :param args: |param_scheduled_cb_args| + :param job_id: + :param kwargs: |param_scheduled_cb_kwargs| + :return: Created job + """ + callback = wrap_func(callback, context=self._habapp_rule_ctx) + + job = OneTimeJob(wrapped_func_executor(callback, args, kwargs), get_instant(instant), job_id=job_id) + run_func_from_async(job.link_scheduler, self._scheduler) + return OneTimeJobControl(job) + + def at(self, trigger: TriggerObject, callback: HINT_CB, + *args: HINT_CB_P.args, + job_id: Hashable | None = None, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + """Create a job that will run when a provided trigger occurs. + + :param trigger: + :param coro_func: |param_scheduled_cb| + :param args: |param_scheduled_cb_args| + :param job_id: + :param kwargs: |param_scheduled_cb_kwargs| + :return: Created job + """ + + # at produces not reoccurring executions, try to make migration graceful + if not isinstance(trigger, TriggerObject): + warnings.warn( + 'self.run.at must be called with a Trigger. Use self.run.once to schedule a single execution', + DeprecationWarning, stacklevel=2 + ) + return self.once(trigger, callback, *args, job_id=job_id, **kwargs) + + callback = wrap_func(callback, context=self._habapp_rule_ctx) + + job = DateTimeJob(wrapped_func_executor(callback, args, kwargs), _get_producer(trigger), job_id=job_id) + run_func_from_async(job.link_scheduler, self._scheduler) + return DateTimeJobControl(job) + + # ------------------------------------------------------------------------------------------------------------------ + # convenience functions + # ------------------------------------------------------------------------------------------------------------------ + def soon(self, callback: HINT_CB, + *args: HINT_CB_P.args, + job_id: Hashable | None = None, **kwargs: HINT_CB_P.kwargs) -> OneTimeJobControl: + """ + Run the callback as soon as possible. + + :param callback: |param_scheduled_cb| + :param args: |param_scheduled_cb_args| + :param kwargs: |param_scheduled_cb_kwargs| + """ + return self.once(None, callback, *args, job_id=job_id, **kwargs) + + # ------------------------------------------------------------------------------------------------------------------ + # deprecated functions + # ------------------------------------------------------------------------------------------------------------------ + def every(self, start_time, interval, + callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + + warnings.warn( + 'self.run.every is deprecated. Use self.run.at in combination with a trigger', + DeprecationWarning, stacklevel=2 + ) + + return self.at( + TriggerBuilder.interval(start_time, interval), + callback, *args, **kwargs + ) + + def on_day_of_week(self, time, weekdays, + callback: HINT_CB, *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + warnings.warn( + 'self.run.on_day_of_week is deprecated. Use self.run.at in combination with a trigger and a filter', + DeprecationWarning, stacklevel=2 + ) + + if isinstance(time, dt_datetime): + time = time.time() + + return self.at( + TriggerBuilder.time(time).only_at(FilterBuilder.weekdays(weekdays)), + callback, *args, **kwargs + ) + + def on_every_day(self, time, callback: HINT_CB, + *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + warnings.warn( + 'self.run.on_every_day is deprecated. Use self.run.at in combination with a trigger', + DeprecationWarning, stacklevel=2 + ) + + if isinstance(time, dt_datetime): + time = time.time() + + return self.at( + TriggerBuilder.time(time), + callback, *args, **kwargs + ) + + def on_sunrise(self, callback: HINT_CB, + *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + warnings.warn( + 'self.run.on_sunrise is deprecated. Use self.run.at in combination with a trigger', + DeprecationWarning, stacklevel=2 + ) + return self.at(TriggerBuilder.sunrise(), callback, *args, **kwargs) + + def on_sunset(self, callback: HINT_CB, + *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + warnings.warn( + 'self.run.on_sunset is deprecated. Use self.run.at in combination with a trigger', + DeprecationWarning, stacklevel=2 + ) + return self.at(TriggerBuilder.sunset(), callback, *args, **kwargs) + + def on_sun_dawn(self, callback: HINT_CB, + *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + warnings.warn( + 'self.run.on_sun_dawn is deprecated. Use self.run.at in combination with a trigger', + DeprecationWarning, stacklevel=2 + ) + return self.at(TriggerBuilder.dawn(), callback, *args, **kwargs) + + def on_sun_dusk(self, callback: HINT_CB, + *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + warnings.warn( + 'self.run.on_sun_dusk is deprecated. Use self.run.at in combination with a trigger', + DeprecationWarning, stacklevel=2 + ) + return self.at(TriggerBuilder.dusk(), callback, *args, **kwargs) + + def every_minute(self, callback: HINT_CB, + *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + warnings.warn( + 'self.run.every_minute is deprecated. Use self.run.at in combination with a trigger', + DeprecationWarning, stacklevel=2 + ) + return self.at(TriggerBuilder.interval(random.randint(0, 60 - 1), 60), callback, *args, **kwargs) + + def every_hour(self, callback: HINT_CB, + *args: HINT_CB_P.args, **kwargs: HINT_CB_P.kwargs) -> DateTimeJobControl: + warnings.warn( + 'self.run.every_hour is deprecated. Use self.run.at in combination with a trigger', + DeprecationWarning, stacklevel=2 + ) + return self.at(TriggerBuilder.interval(random.randint(0, 3600 - 1), 3600), callback, *args, **kwargs) diff --git a/src/HABApp/rule/scheduler/job_ctrl.py b/src/HABApp/rule/scheduler/job_ctrl.py new file mode 100644 index 00000000..fdfb22d2 --- /dev/null +++ b/src/HABApp/rule/scheduler/job_ctrl.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Final + +from eascheduler.job_control.base import BaseControl +from typing_extensions import Self, override +from whenever import Instant + +from HABApp.core.asyncio import run_func_from_async +from HABApp.core.internals import uses_item_registry +from HABApp.core.items import BaseValueItem +from HABApp.openhab.items import OpenhabItem + + +if TYPE_CHECKING: + from datetime import datetime as dt_datetime + from datetime import timedelta as dt_timedelta + + from eascheduler.jobs import CountdownJob, DateTimeJob, OneTimeJob + from eascheduler.jobs.base import JobBase + + +Items = uses_item_registry() + + +class HABAppBaseControl(BaseControl): + def __init__(self) -> None: + super().__init__() + self._item: BaseValueItem | None = None + + def _cancel_timestamp_to_item(self, job: JobBase | None) -> None: + self._timestamp_to_item(None) + self._item = None + self._job.on_update.remove(self._timestamp_to_item) + self._job.on_finished.remove(self._cancel_timestamp_to_item) + + def _timestamp_to_item(self, job: JobBase | None) -> None: + if self._item is None: + return None + + if isinstance(self._item, OpenhabItem): + self._item.oh_post_update(self.next_run_datetime) + else: + self._item.post_value(self.next_run_datetime) + + def to_item(self, item: str | BaseValueItem | None) -> None: + """Sends the next execution (date)time to an item. Sends ``None`` if the job is not scheduled. + Every time the scheduler updates to a new (date)time the item will also receive the updated time. + + :param item: item name or item, ``None`` to disable + """ + if item is None: + self._cancel_timestamp_to_item(None) + return None + + self._item = Items.get_item(item if not isinstance(item, BaseValueItem) else item.name) + self._job.on_update.register(self._timestamp_to_item) + self._job.on_finished.register(self._cancel_timestamp_to_item) + # Update the item with the current timestamp + self._timestamp_to_item(None) + + @override + def cancel(self: Self) -> Self: + """Cancel the job""" + + run_func_from_async(self._job.job_finish) + return self + + def get_next_run(self) -> dt_datetime | None: + + warnings.warn( + 'job.get_next_run() is deprecated. Use job.next_run_datetime', + DeprecationWarning, stacklevel=2 + ) + return self.next_run_datetime + + def remaining(self) -> dt_timedelta | None: + + warnings.warn( + 'job.remaining() is deprecated. Use job.next_run_datetime to get the next execution time or None or' + ' job.status to see if the job is running or not', + DeprecationWarning, stacklevel=2 + ) + if (nr := self._job.next_run) is None: + return None + return (Instant.now() - nr).py_timedelta() + + +class CountdownJobControl(HABAppBaseControl): + def __init__(self, job: CountdownJob) -> None: + super().__init__() + self._job: Final[CountdownJob] = job # type: ignore[misc] + + def set_countdown(self, secs: float) -> Self: + """Set the countdown time""" + run_func_from_async(self._job.set_countdown, secs) + return self + + def stop(self) -> Self: + """Stop the countdown""" + run_func_from_async(self._job.job_pause) + return self + + def reset(self) -> Self: + """Start the countdown again""" + run_func_from_async(self._job.reset) + return self + + +class OneTimeJobControl(HABAppBaseControl): + def __init__(self, job: OneTimeJob) -> None: + super().__init__() + self._job: Final = job # type: ignore[misc] + + +class DateTimeJobControl(HABAppBaseControl): + def __init__(self, job: DateTimeJob) -> None: + super().__init__() + self._job: Final = job # type: ignore[misc] + + def pause(self) -> Self: + """Stop executing this job""" + run_func_from_async(self._job.job_pause) + return self + + def resume(self) -> Self: + """Resume executing this job""" + run_func_from_async(self._job.job_resume) + return self diff --git a/src/HABApp/rule/scheduler/jobs.py b/src/HABApp/rule/scheduler/jobs.py deleted file mode 100644 index 3b3c9466..00000000 --- a/src/HABApp/rule/scheduler/jobs.py +++ /dev/null @@ -1,120 +0,0 @@ -from __future__ import annotations - -import inspect - -import eascheduler.scheduler_view -from eascheduler.const import FAR_FUTURE -from eascheduler.jobs import CountdownJob as CountdownJobBase -from eascheduler.jobs import DawnJob as DawnJobBase -from eascheduler.jobs import DayOfWeekJob as DayOfWeekJobBase -from eascheduler.jobs import DuskJob as DuskJobBase -from eascheduler.jobs import OneTimeJob as OneTimeJobBase -from eascheduler.jobs import ReoccurringJob as ReoccurringJobBase -from eascheduler.jobs import SunriseJob as SunriseJobBase -from eascheduler.jobs import SunsetJob as SunsetJobBase -from eascheduler.jobs.job_base import ScheduledJobBase - -from HABApp.core.internals import uses_item_registry -from HABApp.core.items import BaseValueItem -from HABApp.core.wrapper import ignore_exception -from HABApp.openhab.items import OpenhabItem - - -Items = uses_item_registry() - - -class ItemBoundJobMixin: - def __init__(self): - super().__init__() - self._item: BaseValueItem | None = None - - @ignore_exception - def _timestamp_to_item(self, job: ScheduledJobBase): - if self._item is None: - return None - - if self._next_run >= FAR_FUTURE: - next_run = None - else: - next_run = self.get_next_run() - - if isinstance(self._item, OpenhabItem): - self._item.oh_post_update(next_run) - else: - self._item.post_value(next_run) - - # if the job has been canceled we clean this up, too - if self._parent is None: - self._item = None - self._next_run_callback = None - - def to_item(self, item: str | BaseValueItem | None): - """Sends the next execution (date)time to an item. Sends ``None`` if the job is not scheduled. - Every time the scheduler updates to a new (date)time the item will also receive the updated time. - - :param item: item name or item, ``None`` to disable - """ - if item is None: - self._item = None - self._next_run_callback = None - else: - self._item = Items.get_item(item) if not isinstance(item, BaseValueItem) else item - self._next_run_callback = self._timestamp_to_item - # Update the item with the current timestamp - self._timestamp_to_item(self) - - # I am not sure about this operator - it seems like a nice idea but doesn't work well - # with the code inspections from the IDE - # - # def __gt__(self, other): - # if isinstance(other, (str, BaseValueItem, None)): - # self.to_item(other) - # return self - # return NotImplemented - - -class CountdownJob(CountdownJobBase, ItemBoundJobMixin): - pass - - -class DawnJob(DawnJobBase, ItemBoundJobMixin): - pass - - -class DayOfWeekJob(DayOfWeekJobBase, ItemBoundJobMixin): - pass - - -class DuskJob(DuskJobBase, ItemBoundJobMixin): - pass - - -class OneTimeJob(OneTimeJobBase, ItemBoundJobMixin): - pass - - -class ReoccurringJob(ReoccurringJobBase, ItemBoundJobMixin): - pass - - -class SunriseJob(SunriseJobBase, ItemBoundJobMixin): - pass - - -class SunsetJob(SunsetJobBase, ItemBoundJobMixin): - pass - - -# This is a very dirty hack - I really should come up with something different -def replace_jobs(): - g = globals() - module = eascheduler.scheduler_view - for name, obj in inspect.getmembers(module): - if not name.endswith('Job'): - continue - - assert obj is g[name + 'Base'] - setattr(module, name, g[name]) - - -replace_jobs() diff --git a/src/HABApp/rule/scheduler/scheduler.py b/src/HABApp/rule/scheduler/scheduler.py deleted file mode 100644 index d8ca324c..00000000 --- a/src/HABApp/rule/scheduler/scheduler.py +++ /dev/null @@ -1,69 +0,0 @@ -from asyncio import run_coroutine_threadsafe - -from eascheduler.jobs.job_base import ScheduledJobBase -from eascheduler.schedulers import AsyncScheduler - -from HABApp.core.asyncio import async_context -from HABApp.core.const import loop - - -class HABAppScheduler(AsyncScheduler): - def __init__(self): - super().__init__() - - # we start paused, so we execute stuff only if we can load the whole file properly - self.pause() - - async def _run_next(self): - async_context.set('HABAppScheduler') - return await super()._run_next() - - async def __add_job(self, job: ScheduledJobBase): - super().add_job(job) - - async def __remove_job(self, job: ScheduledJobBase): - super().remove_job(job) - - async def __cancel_all(self): - super().cancel_all() - - async def __pause(self): - super().pause() - - async def __resume(self): - super().resume() - - def pause(self): - if async_context.get(None) is None: - run_coroutine_threadsafe(self.__pause(), loop).result() - else: - super().pause() - return None - - def resume(self): - if async_context.get(None) is None: - run_coroutine_threadsafe(self.__resume(), loop).result() - else: - super().resume() - return None - - def add_job(self, job: ScheduledJobBase): - if async_context.get(None) is None: - run_coroutine_threadsafe(self.__add_job(job), loop).result() - else: - super().add_job(job) - return None - - def remove_job(self, job: ScheduledJobBase): - if async_context.get(None) is None: - run_coroutine_threadsafe(self.__remove_job(job), loop).result() - else: - super().remove_job(job) - return None - - def cancel_all(self): - if async_context.get(None) is None: - run_coroutine_threadsafe(self.__cancel_all(), loop).result() - else: - super().cancel_all() - return None diff --git a/src/HABApp/rule_ctx/rule_ctx.py b/src/HABApp/rule_ctx/rule_ctx.py index 826568f2..7741a3ce 100644 --- a/src/HABApp/rule_ctx/rule_ctx.py +++ b/src/HABApp/rule_ctx/rule_ctx.py @@ -1,40 +1,52 @@ +from __future__ import annotations + import logging -from typing import Callable, Optional +from typing import TYPE_CHECKING, TypeVar import HABApp from HABApp.core.const.topics import ALL_TOPICS -from HABApp.core.internals import HINT_EVENT_BUS_LISTENER, Context, uses_event_bus, uses_item_registry +from HABApp.core.internals import Context, EventBusListener, uses_event_bus, uses_item_registry from HABApp.core.internals.event_bus import EventBusBaseListener +if TYPE_CHECKING: + from collections.abc import Callable + + from HABApp import Rule + + event_bus = uses_event_bus() item_registry = uses_item_registry() log = logging.getLogger('HABApp.Rule') +TB = TypeVar('TB', bound=EventBusListener) + + class HABAppRuleContext(Context): - def __init__(self, rule: 'HABApp.rule.Rule'): + def __init__(self, rule: Rule) -> None: super().__init__() - self.rule: Optional[HABApp.rule.Rule] = rule + self.rule: Rule | None = rule - def get_callback_name(self, callback: Callable) -> Optional[str]: + def get_callback_name(self, callback: Callable) -> str | None: return f'{self.rule.rule_name}.{callback.__name__}' if self.rule.rule_name else None - def add_event_listener(self, listener: HINT_EVENT_BUS_LISTENER) -> HINT_EVENT_BUS_LISTENER: + def add_event_listener(self, listener: TB) -> TB: event_bus.add_listener(listener) return listener - def remove_event_listener(self, listener: HINT_EVENT_BUS_LISTENER) -> HINT_EVENT_BUS_LISTENER: + def remove_event_listener(self, listener: TB) -> TB: event_bus.remove_listener(listener) return listener - def unload_rule(self): + def unload_rule(self) -> None: with HABApp.core.wrapper.ExceptionToHABApp(log): rule = self.rule # Unload the scheduler - rule.run._scheduler.cancel_all() + rule.run._scheduler.set_enabled(False) + rule.run._scheduler.remove_all() rule.run._habapp_ctx = None # cancel things and set obj to None @@ -51,7 +63,7 @@ def unload_rule(self): # user implementation rule.on_rule_removed() - def check_rule(self): + def check_rule(self) -> None: with HABApp.core.wrapper.ExceptionToHABApp(log): # We need items if we want to run the test if item_registry.get_items(): @@ -71,7 +83,7 @@ def check_rule(self): f'self.listen_event in "{self.rule.rule_name}" may not work as intended.') # enable the scheduler - self.rule.run._scheduler.resume() + self.rule.run._scheduler.set_enabled(True) # user implementation self.rule.on_rule_loaded() diff --git a/src/HABApp/rule_manager/benchmark/bench_base.py b/src/HABApp/rule_manager/benchmark/bench_base.py index b032fc9d..6533f863 100644 --- a/src/HABApp/rule_manager/benchmark/bench_base.py +++ b/src/HABApp/rule_manager/benchmark/bench_base.py @@ -1,20 +1,20 @@ -from typing import Optional import HABApp +from HABApp.core import shutdown from HABApp.core.const.topics import TOPIC_ERRORS class BenchBaseRule(HABApp.Rule): BENCH_TYPE: str - def __init__(self): + def __init__(self) -> None: super().__init__() self.err_watcher = None self.errors = [] - self.prev_rule: Optional[BenchBaseRule] = None - self.next_rule: Optional[BenchBaseRule] = None + self.prev_rule: BenchBaseRule | None = None + self.next_rule: BenchBaseRule | None = None def link_rule(self, next_rule: 'BenchBaseRule'): assert self.next_rule is None @@ -24,16 +24,16 @@ def link_rule(self, next_rule: 'BenchBaseRule'): next_rule.prev_rule = self return next_rule - def _err_event(self, event): + def _err_event(self, event) -> None: self.errors.append(event) - def do_bench_start(self): + def do_bench_start(self) -> None: self.errors.clear() self.err_watcher = self.listen_event(TOPIC_ERRORS, self._err_event) self.run.at(1, self.do_bench_run) - def do_bench_run(self): + def do_bench_run(self) -> None: try: try: print('+' + '-' * 78 + '+') @@ -48,16 +48,16 @@ def do_bench_run(self): finally: self.run.at(1, self.do_bench_finished) - def set_up(self): + def set_up(self) -> None: pass - def tear_down(self): + def tear_down(self) -> None: pass def run_bench(self): raise NotImplementedError() - def do_bench_finished(self): + def do_bench_finished(self) -> None: self.err_watcher.cancel() if self.errors: @@ -67,6 +67,6 @@ def do_bench_finished(self): print(f' - {type(e.exception)}: {e.exception}') if self.next_rule is None: - HABApp.runtime.shutdown.request_shutdown() + shutdown.request() else: self.run.soon(self.next_rule.do_bench_start) diff --git a/src/HABApp/rule_manager/benchmark/bench_file.py b/src/HABApp/rule_manager/benchmark/bench_file.py index 187f531a..24ff7c53 100644 --- a/src/HABApp/rule_manager/benchmark/bench_file.py +++ b/src/HABApp/rule_manager/benchmark/bench_file.py @@ -10,10 +10,10 @@ class BenchFile(RuleFile): - def __init__(self, rule_manager): + def __init__(self, rule_manager) -> None: super().__init__(rule_manager, 'BenchmarkFile', path=Path('BenchmarkFile')) - def create_rules(self, created_rules: list): + def create_rules(self, created_rules: list) -> None: hook = HABAppRuleHook(created_rules.append, self.suggest_rule_name, self.rule_manager.runtime, self) hook.in_dict(globals()) diff --git a/src/HABApp/rule_manager/benchmark/bench_habapp.py b/src/HABApp/rule_manager/benchmark/bench_habapp.py index 586ab9a5..bb975004 100644 --- a/src/HABApp/rule_manager/benchmark/bench_habapp.py +++ b/src/HABApp/rule_manager/benchmark/bench_habapp.py @@ -16,7 +16,7 @@ class HABAppBenchRule(BenchBaseRule): BENCH_TYPE = 'HABApp' - def __init__(self): + def __init__(self) -> None: super().__init__() self.name_list = [f'BenchItem{k}' for k in range(300)] @@ -29,22 +29,22 @@ def __init__(self): self.name = '' self.values = deque() - def cleanup(self): + def cleanup(self) -> None: for n in self.name_list: if HABApp.core.Items.item_exists(n): HABApp.core.Items.pop_item(n) - def set_up(self): + def set_up(self) -> None: self.cleanup() - def tear_down(self): + def tear_down(self) -> None: self.cleanup() - def run_bench(self): + def run_bench(self) -> None: # These are the benchmarks self.bench_rtt_time() - def bench_rtt_time(self): + def bench_rtt_time(self) -> None: print('Bench events ', end='') self.bench_times_container = BenchContainer() @@ -60,7 +60,7 @@ def bench_rtt_time(self): time.sleep(0.1) self.bench_times_container.show() - def run_rtt(self, test_name, do_async=False): + def run_rtt(self, test_name, do_async=False) -> None: self.name = self.name_list[0] self.openhab.create_item('String', self.name, label='MyLabel') @@ -106,5 +106,5 @@ def post_next_event_val(self, value): self.time_sent = time.time() self.post_event(self.name, self.values[0]) - async def a_post_next_event_val(self, event: ValueUpdateEvent): + async def a_post_next_event_val(self, event: ValueUpdateEvent) -> None: self.post_next_event_val(event) diff --git a/src/HABApp/rule_manager/benchmark/bench_mqtt.py b/src/HABApp/rule_manager/benchmark/bench_mqtt.py index 6f0b76ec..570d744f 100644 --- a/src/HABApp/rule_manager/benchmark/bench_mqtt.py +++ b/src/HABApp/rule_manager/benchmark/bench_mqtt.py @@ -17,7 +17,7 @@ class MqttBenchRule(BenchBaseRule): BENCH_TYPE = 'MQTT' - def __init__(self): + def __init__(self) -> None: super().__init__() self.name_list = [f'test/BenchItem{k}' for k in range(300)] @@ -30,22 +30,22 @@ def __init__(self): self.name = '' self.values = deque() - def cleanup(self): + def cleanup(self) -> None: for n in self.name_list: if HABApp.core.Items.item_exists(n): HABApp.core.Items.pop_item(n) - def set_up(self): + def set_up(self) -> None: self.cleanup() - def tear_down(self): + def tear_down(self) -> None: self.cleanup() - def run_bench(self): + def run_bench(self) -> None: # These are the benchmarks self.bench_rtt_time() - def bench_rtt_time(self): + def bench_rtt_time(self) -> None: print('Bench events ', end='') self.bench_times_container = BenchContainer() @@ -61,7 +61,7 @@ def bench_rtt_time(self): time.sleep(0.1) self.bench_times_container.show() - def run_rtt(self, test_name, do_async=False): + def run_rtt(self, test_name, do_async=False) -> None: self.name = self.name_list[0] HABApp.mqtt.items.MqttItem.get_create_item(self.name) @@ -109,5 +109,5 @@ def post_next_event_val(self, event): self.time_sent = time.time() publish(self.name, self.values[0]) - async def a_post_next_event_val(self, event: ValueUpdateEvent): + async def a_post_next_event_val(self, event: ValueUpdateEvent) -> None: self.post_next_event_val(event) diff --git a/src/HABApp/rule_manager/benchmark/bench_oh.py b/src/HABApp/rule_manager/benchmark/bench_oh.py index 3dba20ba..04cb8e99 100644 --- a/src/HABApp/rule_manager/benchmark/bench_oh.py +++ b/src/HABApp/rule_manager/benchmark/bench_oh.py @@ -18,7 +18,7 @@ class OpenhabBenchRule(BenchBaseRule): RTT_BENCH_MAX = 15 - def __init__(self): + def __init__(self) -> None: super().__init__() self.name_list = [f'BenchItem{k}' for k in range(300)] @@ -47,18 +47,18 @@ def cleanup(self): self.oh.remove_item(name) print('complete') - def set_up(self): + def set_up(self) -> None: self.cleanup() - def tear_down(self): + def tear_down(self) -> None: self.cleanup() - def run_bench(self): + def run_bench(self) -> None: # These are the benchmarks self.bench_item_create() self.bench_rtt_time() - def bench_item_create(self): + def bench_item_create(self) -> None: print('Bench item operations ', end='') max_duration = 10 # how long should each bench take @@ -107,7 +107,7 @@ def bench_item_create(self): print('. done!\n') times.show() - def bench_rtt_time(self): + def bench_rtt_time(self) -> None: print('Bench item state update ', end='') self.bench_times_container = BenchContainer() @@ -122,10 +122,10 @@ def bench_rtt_time(self): print(' done!\n') self.bench_times_container.show() - def start_load(self): + def start_load(self) -> None: for i in range(10, 20): - def load_cb(event, item=self.name_list[i]): + def load_cb(event, item=self.name_list[i]) -> None: self.openhab.post_update(item, str(random.randint(0, 99999999))) self.openhab.create_item('String', self.name_list[i], label='MyLabel') @@ -134,12 +134,12 @@ def load_cb(event, item=self.name_list[i]): self.load_listener.append(listener) self.openhab.post_update(self.name_list[i], str(random.randint(0, 99999999))) - def stop_load(self): + def stop_load(self) -> None: for list in self.load_listener: list.cancel() self.load_listener.clear() - def run_rtt(self, test_name, do_async=False): + def run_rtt(self, test_name, do_async=False) -> None: self.item_name = self.name_list[0] self.openhab.create_item('String', self.item_name, label='MyLabel') @@ -187,5 +187,5 @@ def proceed_item_val(self, event: ValueUpdateEvent): self.time_sent = time.time() self.openhab.post_update(self.item_name, self.item_values[0]) - async def a_proceed_item_val(self, event: ValueUpdateEvent): + async def a_proceed_item_val(self, event: ValueUpdateEvent) -> None: self.proceed_item_val(event) diff --git a/src/HABApp/rule_manager/benchmark/bench_times.py b/src/HABApp/rule_manager/benchmark/bench_times.py index 4c3fc7f4..90b325ae 100644 --- a/src/HABApp/rule_manager/benchmark/bench_times.py +++ b/src/HABApp/rule_manager/benchmark/bench_times.py @@ -1,8 +1,7 @@ from statistics import mean, median -from typing import Union -def format_duration(duration: Union[None, str, float]) -> str: +def format_duration(duration: None | str | float) -> str: if duration is None: return ' ' * 6 if isinstance(duration, str): @@ -26,7 +25,7 @@ def format_duration(duration: Union[None, str, float]) -> str: class BenchContainer: - def __init__(self): + def __init__(self) -> None: self.times = [] def create(self, name: str) -> 'BenchTime': @@ -34,7 +33,7 @@ def create(self, name: str) -> 'BenchTime': self.times.append(c) return c - def show(self): + def show(self) -> None: indent = max(map(lambda x: len(x.name), self.times), default=0) BenchTime.show_table(indent) for b in self.times: @@ -45,16 +44,16 @@ def show(self): class BenchTime: @classmethod - def show_table(cls, indent_name=0): + def show_table(cls, indent_name=0) -> None: print(f'{"":{indent_name}s} | {format_duration("dur")} | {"per sec":7s} | {format_duration("median")} | ' f'{format_duration("min")} | {format_duration("max")} | {format_duration("mean")}') - def __init__(self, name: str, factor: int = 1): + def __init__(self, name: str, factor: int = 1) -> None: self.name = name self.times = [] self.factor = factor - def show(self, indent_name=0): + def show(self, indent_name=0) -> None: total = sum(self.times) count = len(self.times) _mean = mean(self.times) if self.times else 0 diff --git a/src/HABApp/rule_manager/rule_file.py b/src/HABApp/rule_manager/rule_file.py index e6e16dfe..3cf98c64 100644 --- a/src/HABApp/rule_manager/rule_file.py +++ b/src/HABApp/rule_manager/rule_file.py @@ -13,7 +13,7 @@ class RuleFile: - def __init__(self, rule_manager, name: str, path: Path): + def __init__(self, rule_manager, name: str, path: Path) -> None: from .rule_manager import RuleManager assert isinstance(rule_manager, RuleManager) @@ -38,7 +38,7 @@ def suggest_rule_name(self, obj: 'HABApp.Rule') -> str: return f'{name:s}.{found:d}' if found > 1 else f'{name:s}' - def check_all_rules(self): + def check_all_rules(self) -> None: for rule in self.rules.values(): # type: HABApp.Rule get_current_context(rule).check_rule() @@ -59,7 +59,7 @@ def __process_tc(self, tb: list): tb.insert(0, f'Could not load {self.path}!') return [line.replace('', self.path.name) for line in tb] - def create_rules(self, created_rules: list): + def create_rules(self, created_rules: list) -> None: rule_hook = HABAppRuleHook(created_rules.append, self.suggest_rule_name, self.rule_manager.runtime, self) diff --git a/src/HABApp/rule_manager/rule_manager.py b/src/HABApp/rule_manager/rule_manager.py index 2eb0fb8f..397ad8a7 100644 --- a/src/HABApp/rule_manager/rule_manager.py +++ b/src/HABApp/rule_manager/rule_manager.py @@ -6,17 +6,17 @@ import HABApp import HABApp.__cmd_args__ as cmd_args +from HABApp.core import shutdown from HABApp.core.connections import Connections from HABApp.core.files.errors import AlreadyHandledFileError from HABApp.core.files.file import HABAppFile from HABApp.core.files.folders import add_folder as add_habapp_folder from HABApp.core.files.watcher import AggregatingAsyncEventHandler from HABApp.core.internals import uses_item_registry -from HABApp.core.internals.wrapped_function import run_function +from HABApp.core.internals.wrapped_function import wrap_func from HABApp.core.logger import log_warning from HABApp.core.wrapper import log_exception from HABApp.rule_manager.rule_file import RuleFile -from HABApp.runtime import shutdown log = logging.getLogger('HABApp.Rules') @@ -26,7 +26,7 @@ class RuleManager: - def __init__(self, parent): + def __init__(self, parent) -> None: assert isinstance(parent, HABApp.runtime.Runtime) self.runtime = parent @@ -39,20 +39,20 @@ def __init__(self, parent): # Processing self.__process_last_sec = 60 - self.watcher: typing.Optional[AggregatingAsyncEventHandler] = None + self.watcher: AggregatingAsyncEventHandler | None = None async def setup(self): # shutdown - shutdown.register_func(self.shutdown, msg='Cancel rule schedulers') + shutdown.register(self.shutdown, msg='Cancel rule schedulers') if cmd_args.DO_BENCH: from HABApp.rule_manager.benchmark import BenchFile self.files['bench'] = file = BenchFile(self) - ok = await run_function(file.load) + ok = await wrap_func(file.load).async_run() if not ok: log.error('Failed to load Benchmark!') - HABApp.runtime.shutdown.request_shutdown() + shutdown.request() return None file.check_all_rules() return @@ -80,7 +80,7 @@ async def load_rules_on_startup(self): await sleep(1) # if we want to shut down we don't load the rules - if HABApp.runtime.shutdown.requested: + if shutdown.is_requested(): return None # trigger event for every file @@ -125,7 +125,7 @@ async def request_file_unload(self, name: str, path: Path, request_lock=True): with self.__files_lock: rule = self.files.pop(path_str) - await run_function(rule.unload) + await wrap_func(rule.unload).async_run() finally: if request_lock: self.__load_lock.release() @@ -134,7 +134,7 @@ async def request_file_load(self, name: str, path: Path): path_str = str(path) # if we want to shut down we don't load the rules - if HABApp.runtime.shutdown.requested: + if shutdown.is_requested(): log.debug(f'Skip load of {name:s} because of shutdown') return None @@ -154,7 +154,7 @@ async def request_file_load(self, name: str, path: Path): with self.__files_lock: self.files[path_str] = file = RuleFile(self, name, path) - ok = await run_function(file.load) + ok = await wrap_func(file.load).async_run() if not ok: self.files.pop(path_str) log.warning(f'Failed to load {path_str}!') @@ -165,6 +165,6 @@ async def request_file_load(self, name: str, path: Path): # Do simple checks which prevent errors file.check_all_rules() - def shutdown(self): + def shutdown(self) -> None: for f in self.files.values(): f.unload() diff --git a/src/HABApp/runtime/__init__.py b/src/HABApp/runtime/__init__.py index e0bada46..529b7e65 100644 --- a/src/HABApp/runtime/__init__.py +++ b/src/HABApp/runtime/__init__.py @@ -1,2 +1 @@ -from . import shutdown from .runtime import Runtime diff --git a/src/HABApp/runtime/runtime.py b/src/HABApp/runtime/runtime.py index 7cd474fa..05675b9e 100644 --- a/src/HABApp/runtime/runtime.py +++ b/src/HABApp/runtime/runtime.py @@ -11,29 +11,28 @@ import HABApp.rule.interfaces._http import HABApp.rule_manager import HABApp.util -from HABApp.core import Connections +from HABApp.core import Connections, shutdown from HABApp.core.asyncio import async_context from HABApp.core.internals import setup_internals from HABApp.core.internals.proxy import ConstProxyObj from HABApp.core.wrapper import process_exception from HABApp.openhab import connection as openhab_connection -from HABApp.runtime import shutdown class Runtime: - def __init__(self): + def __init__(self) -> None: self.config: HABApp.config.Config = None # Rule engine self.rule_manager: HABApp.rule_manager.RuleManager = None - async def start(self, config_folder: Path): + async def start(self, config_folder: Path) -> None: try: token = async_context.set('HABApp startup') # shutdown setup - shutdown.register_func(Connections.on_application_shutdown, msg='Shutting down connections') + shutdown.register(Connections.on_application_shutdown, msg='Shutting down connections') # setup exception handler for the scheduler eascheduler.set_exception_handler(lambda x: process_exception('HABApp.scheduler', x)) @@ -75,8 +74,8 @@ async def start(self, config_folder: Path): async_context.reset(token) except HABApp.config.InvalidConfigError: - shutdown.request_shutdown() + shutdown.request() except Exception as e: process_exception('Runtime.start', e) await asyncio.sleep(1) # Sleep so we can do a graceful shutdown - shutdown.request_shutdown() + shutdown.request() diff --git a/src/HABApp/runtime/shutdown.py b/src/HABApp/runtime/shutdown.py deleted file mode 100644 index bdfadbd7..00000000 --- a/src/HABApp/runtime/shutdown.py +++ /dev/null @@ -1,81 +0,0 @@ -import itertools -import logging -import logging.handlers -import signal -import traceback -import typing -from asyncio import iscoroutinefunction, sleep -from dataclasses import dataclass -from types import FunctionType, MethodType -from typing import Callable, Coroutine, Union - -from HABApp.core.asyncio import async_context, create_task -from HABApp.core.const import loop - - -@dataclass(frozen=True) -class ShutdownInfo: - func: Union[Callable[[], typing.Any], Coroutine] - msg: str - last: bool - - -_FUNCS: typing.List[ShutdownInfo] = [] - -requested: bool = False - - -def register_func(func, last=False, msg: str = ''): - assert isinstance(func, (FunctionType, MethodType)) or iscoroutinefunction(func), print(type(func)) - assert last is True or last is False, last - assert isinstance(msg, str) - - _FUNCS.append(ShutdownInfo(func, msg if msg else f'{func.__module__}.{func.__name__}', last)) - - -def register_signal_handler(): - def shutdown_handler(sig, frame): - print('Shutting down ...') - request_shutdown() - - # register shutdown helper - signal.signal(signal.SIGINT, shutdown_handler) - signal.signal(signal.SIGTERM, shutdown_handler) - - -def request_shutdown(): - create_task(_shutdown()) - - -async def _shutdown(): - global requested - 'Request execution of all functions' - - async_context.set('Shutdown') - - log = logging.getLogger('HABApp.Shutdown') - log.debug('Requested shutdown') - - requested = True - - for obj in itertools.chain(filter(lambda x: not x.last, _FUNCS), - filter(lambda x: x.last, _FUNCS), - # shutdown of the event loop has to be the last thing that is done - # since stopping of the loop exits the program - [ShutdownInfo(loop.stop, 'Stopping asyncio loop', last=True)]): - try: - log.debug(f'{obj.msg}') - if iscoroutinefunction(obj.func): - await obj.func() - else: - obj.func() - log.debug('-> done!') - await sleep(0.02) - except Exception as ex: - log.error(ex) - tb = traceback.format_exc().splitlines() - for line in tb: - log.error(line) - - log.debug('Shutdown complete') - return None diff --git a/src/HABApp/util/fade/fade.py b/src/HABApp/util/fade/fade.py index 6d55ec33..f8b66ff9 100644 --- a/src/HABApp/util/fade/fade.py +++ b/src/HABApp/util/fade/fade.py @@ -1,6 +1,6 @@ from datetime import timedelta from time import time -from typing import Optional, Union +from typing import Union from HABApp.core.internals import AutoContextBoundObj, wrap_func @@ -10,12 +10,12 @@ class FadeWorker(AutoContextBoundObj): - def __init__(self, parent: 'Fade', interval: float): + def __init__(self, parent: 'Fade', interval: float) -> None: super().__init__() self.parent: Fade = parent self.scheduler = self._parent_ctx.rule.run.every(None, interval, self.parent._scheduled_worker) - def cancel(self): + def cancel(self) -> None: self._ctx_unlink() self.scheduler.cancel() self.scheduler = None @@ -35,7 +35,7 @@ class Fade: :ivar callback: Function with one argument that will be automatically called with the new values when the scheduled fade runs """ - def __init__(self, callback=None, min_value: VAL_TYPE = 0, max_value: VAL_TYPE = 100): + def __init__(self, callback=None, min_value: VAL_TYPE = 0, max_value: VAL_TYPE = 100) -> None: self.min_value = min_value self.max_value = max_value @@ -46,13 +46,13 @@ def __init__(self, callback=None, min_value: VAL_TYPE = 0, max_value: VAL_TYPE = self._fade_factor = 0 self._fade_finished = True - self._fade_worker: Optional[FadeWorker] = None + self._fade_worker: FadeWorker | None = None self.__callback = wrap_func(callback) if callback is not None else None self.value = 0 - def setup(self, start_value: VAL_TYPE, stop_value: VAL_TYPE, duration: Union[int, float, timedelta], - min_step_duration: float = MIN_STEP_TIME, now: Optional[float] = None) -> 'Fade': + def setup(self, start_value: VAL_TYPE, stop_value: VAL_TYPE, duration: int | float | timedelta, + min_step_duration: float = MIN_STEP_TIME, now: float | None = None) -> 'Fade': """Calculates everything that is needed to fade a value :param start_value: Start value @@ -87,7 +87,7 @@ def setup(self, start_value: VAL_TYPE, stop_value: VAL_TYPE, duration: Union[int self._fade_finished = False return self - def get_value(self, now: Optional[float] = None) -> float: + def get_value(self, now: float | None = None) -> float: """Returns the current value. If the fade is finished it will always return the stop value. :param now: time.time() timestamp for which the value shall be returned. Can be used to sync multiple fades @@ -121,7 +121,7 @@ def is_finished(self) -> bool: """True if the fade is finished""" return self._fade_finished - async def _scheduled_worker(self): + async def _scheduled_worker(self) -> None: self.get_value() if self._fade_finished: self.stop_fade() diff --git a/src/HABApp/util/listener_groups/listener_creator.py b/src/HABApp/util/listener_groups/listener_creator.py index a0c6bd5d..13009584 100644 --- a/src/HABApp/util/listener_groups/listener_creator.py +++ b/src/HABApp/util/listener_groups/listener_creator.py @@ -1,29 +1,30 @@ -from typing import Any, Callable, Optional, Union +from collections.abc import Callable +from typing import Any, Final from HABApp.core.events import EventFilter from HABApp.core.internals import EventBusListener -from HABApp.core.items import HINT_ITEM_OBJ +from HABApp.core.items import BaseItem class ListenerCreatorBase: - def __init__(self, item: HINT_ITEM_OBJ, callback: Callable[[Any], Any]): - self.item = item - self.callback = callback + def __init__(self, item: BaseItem, callback: Callable[[Any], Any]) -> None: + self.item: Final = item + self.callback: Final = callback - self.listener: Optional[EventBusListener] = None + self.listener: EventBusListener | None = None self.active = True def create_listener(self) -> EventBusListener: raise NotImplementedError() - def listen(self): + def listen(self) -> None: if not self.active: return None if self.listener is None: self.listener = self.create_listener() - def cancel(self): + def cancel(self) -> None: if not self.active: return None @@ -33,7 +34,7 @@ def cancel(self): class EventListenerCreator(ListenerCreatorBase): - def __init__(self, item: HINT_ITEM_OBJ, callback: Callable[[Any], Any], event_filter: EventFilter): + def __init__(self, item: BaseItem, callback: Callable[[Any], Any], event_filter: EventFilter) -> None: super().__init__(item, callback) self.event_filter = event_filter @@ -42,7 +43,7 @@ def create_listener(self) -> EventBusListener: class NoUpdateEventListenerCreator(ListenerCreatorBase): - def __init__(self, item: HINT_ITEM_OBJ, callback: Callable[[Any], Any], secs: Union[int, float]): + def __init__(self, item: BaseItem, callback: Callable[[Any], Any], secs: int | float) -> None: super().__init__(item, callback) self.secs = secs @@ -51,7 +52,7 @@ def create_listener(self) -> EventBusListener: class NoChangeEventListenerCreator(ListenerCreatorBase): - def __init__(self, item: HINT_ITEM_OBJ, callback: Callable[[Any], Any], secs: Union[int, float]): + def __init__(self, item: BaseItem, callback: Callable[[Any], Any], secs: int | float) -> None: super().__init__(item, callback) self.secs = secs diff --git a/src/HABApp/util/listener_groups/listener_groups.py b/src/HABApp/util/listener_groups/listener_groups.py index 193384b3..79a88c9d 100644 --- a/src/HABApp/util/listener_groups/listener_groups.py +++ b/src/HABApp/util/listener_groups/listener_groups.py @@ -1,8 +1,8 @@ -from typing import Any, Callable, Dict, Iterable, Optional, Union +from __future__ import annotations -from HABApp.core.internals import HINT_EVENT_FILTER_OBJ -from HABApp.core.items import HINT_ITEM_OBJ, BaseItem -from HABApp.core.lib.parameters import TH_POSITIVE_TIME_DIFF +from typing import TYPE_CHECKING, Any + +from HABApp.core.items import BaseItem from .listener_creator import ( EventListenerCreator, @@ -12,9 +12,17 @@ ) +if TYPE_CHECKING: + from collections.abc import Callable, Iterable + + from eascheduler.builder.helper import HINT_POS_TIMEDELTA + + from HABApp.core.internals import EventFilterBase + + class ListenerCreatorNotFoundError(Exception): @classmethod - def from_name(cls, name: str): + def from_name(cls, name: str) -> ListenerCreatorNotFoundError: return cls(f'ListenerCreator for "{name}" not found!') @@ -22,8 +30,8 @@ class EventListenerGroup: """Helper to create/cancel multiple event listeners simultaneously """ - def __init__(self): - self._items: Dict[str, ListenerCreatorBase] = {} + def __init__(self) -> None: + self._items: dict[str, ListenerCreatorBase] = {} self._is_active = False @property @@ -34,7 +42,7 @@ def active(self) -> bool: """ return self._is_active - def listen(self): + def listen(self) -> None: """Create all event listeners. If the event listeners are already active this will do nothing. """ if self._is_active: @@ -44,7 +52,7 @@ def listen(self): for o in self._items.values(): o.listen() - def cancel(self): + def cancel(self) -> None: """Cancel the active event listeners. If the event listeners are not active this will do nothing. """ if not self._is_active: @@ -54,7 +62,7 @@ def cancel(self): for o in self._items.values(): o.cancel() - def activate_listener(self, name: str): + def activate_listener(self, name: str) -> bool: """Resume a previously deactivated listener creator in the group. :param name: item name or alias of the listener @@ -72,7 +80,7 @@ def activate_listener(self, name: str): obj.listen() return True - def deactivate_listener(self, name: str, cancel_if_active=True): + def deactivate_listener(self, name: str, cancel_if_active: bool = True) -> bool: """Exempt the listener creator from further listener/cancel calls :param name: item name or alias of the listener @@ -91,12 +99,12 @@ def deactivate_listener(self, name: str, cancel_if_active=True): obj.active = False return True - def __add_objs(self, cls, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], - arg, alias: Optional[str] = None): + def __add_objs(self, cls: type[ListenerCreatorBase], item: BaseItem | Iterable[BaseItem], + callback: Callable[[Any], Any], arg, alias: str | None = None) -> None: # alias -> single param - if alias is not None: - if not isinstance(item, BaseItem): - raise ValueError('Only a single item can be passed together with alias') + if alias is not None and not isinstance(item, BaseItem): + msg = 'Only a single item can be passed together with alias' + raise ValueError(msg) if isinstance(item, BaseItem): item = [item] @@ -107,8 +115,8 @@ def __add_objs(self, cls, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], c if self._is_active: obj.listen() - def add_listener(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], - event_filter: HINT_EVENT_FILTER_OBJ, alias: Optional[str] = None) -> 'EventListenerGroup': + def add_listener(self, item: BaseItem | Iterable[BaseItem], callback: Callable[[Any], Any], + event_filter: EventFilterBase, alias: str | None = None) -> EventListenerGroup: """Add an event listener to the group :param item: Single or multiple items @@ -122,9 +130,9 @@ def add_listener(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], call self.__add_objs(EventListenerCreator, item, callback, event_filter, alias) return self - def add_no_update_watcher(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], - seconds: TH_POSITIVE_TIME_DIFF, alias: Optional[str] = None - ) -> 'EventListenerGroup': + def add_no_update_watcher(self, item: BaseItem | Iterable[BaseItem], callback: Callable[[Any], Any], + seconds: HINT_POS_TIMEDELTA, alias: str | None = None + ) -> EventListenerGroup: """Add an no update watcher to the group. On ``listen`` this will create a no update watcher and the corresponding event listener that will trigger the callback @@ -138,9 +146,9 @@ def add_no_update_watcher(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OB self.__add_objs(NoUpdateEventListenerCreator, item, callback, seconds, alias) return self - def add_no_change_watcher(self, item: Union[HINT_ITEM_OBJ, Iterable[HINT_ITEM_OBJ]], callback: Callable[[Any], Any], - seconds: TH_POSITIVE_TIME_DIFF, alias: Optional[str] = None - ) -> 'EventListenerGroup': + def add_no_change_watcher(self, item: BaseItem | Iterable[BaseItem], callback: Callable[[Any], Any], + seconds: HINT_POS_TIMEDELTA, alias: str | None = None + ) -> EventListenerGroup: """Add a no change watcher to the group. On ``listen`` this will create a no change watcher and the corresponding event listener that will trigger the callback diff --git a/src/HABApp/util/multimode/item.py b/src/HABApp/util/multimode/item.py index 01753561..cc084de7 100644 --- a/src/HABApp/util/multimode/item.py +++ b/src/HABApp/util/multimode/item.py @@ -1,5 +1,5 @@ from threading import Lock -from typing import Any, Dict, List, Optional, Tuple +from typing import Any from HABApp.core.const import MISSING from HABApp.core.items import Item @@ -27,11 +27,11 @@ def get_create_item(cls, name: str, initial_value=None, default_value=MISSING) - item._default_value = default_value return item - def __init__(self, name: str, initial_value=None, default_value=MISSING): + def __init__(self, name: str, initial_value=None, default_value=MISSING) -> None: super().__init__(name=name, initial_value=initial_value) - self.__values_by_prio: Dict[int, HINT_BASE_MODE] = {} - self.__values_by_name: Dict[str, HINT_BASE_MODE] = {} + self.__values_by_prio: dict[int, HINT_BASE_MODE] = {} + self.__values_by_name: dict[str, HINT_BASE_MODE] = {} self._default_value = default_value @@ -58,7 +58,7 @@ def __sort_modes(self): modes = sorted(self.__values_by_prio.items()) self.__values_by_prio.clear() - lower_mode: Optional[HINT_BASE_MODE] = None + lower_mode: HINT_BASE_MODE | None = None for prio, mode in modes: self.__values_by_prio[prio] = mode mode._set_mode_lower_prio(lower_mode) @@ -103,7 +103,7 @@ def add_mode(self, priority: int, mode: HINT_BASE_MODE) -> 'MultiModeItem': self.__sort_modes() return self - def all_modes(self) -> List[Tuple[int, HINT_BASE_MODE]]: + def all_modes(self) -> list[tuple[int, HINT_BASE_MODE]]: """Returns a sorted list containing tuples with the priority and the mode :return: List with priorities and modes @@ -143,7 +143,7 @@ def calculate_value(self) -> Any: return new_value - def _on_item_removed(self): + def _on_item_removed(self) -> None: for p, mode in self.all_modes(): mode.cancel() diff --git a/src/HABApp/util/multimode/mode_base.py b/src/HABApp/util/multimode/mode_base.py index 57e22aba..5929720e 100644 --- a/src/HABApp/util/multimode/mode_base.py +++ b/src/HABApp/util/multimode/mode_base.py @@ -1,4 +1,4 @@ -from typing import TYPE_CHECKING, Any, Optional, TypeVar +from typing import TYPE_CHECKING, Any, TypeVar import HABApp from HABApp.core.internals import AutoContextBoundObj @@ -6,11 +6,11 @@ class BaseMode(AutoContextBoundObj): - def __init__(self, name: str): + def __init__(self, name: str) -> None: super().__init__() self.name: str = name - self.__mode_lower_prio: Optional[BaseMode] = None + self.__mode_lower_prio: BaseMode | None = None self.parent: HABApp.util.multimode.MultiModeItem @@ -19,14 +19,14 @@ def __init__(self, name: str): self.parent = HABApp.util.multimode.MultiModeItem('TYPE_CHECKING') return - def _set_mode_lower_prio(self, mode_lower_prio): + def _set_mode_lower_prio(self, mode_lower_prio) -> None: assert isinstance(mode_lower_prio, BaseMode) or mode_lower_prio is None, type(mode_lower_prio) self.__lower_priority_mode = mode_lower_prio def calculate_value(self, lower_prio_value: Any) -> Any: raise NotImplementedError() - def cancel(self): + def cancel(self) -> None: """Remove the mode from the parent ``MultiModeItem`` and stop processing it """ self._ctx_unlink() diff --git a/src/HABApp/util/multimode/mode_switch.py b/src/HABApp/util/multimode/mode_switch.py index c8b47f8d..c402f46d 100644 --- a/src/HABApp/util/multimode/mode_switch.py +++ b/src/HABApp/util/multimode/mode_switch.py @@ -1,5 +1,4 @@ import logging -import typing import HABApp @@ -28,9 +27,9 @@ def __init__(self, name: str, switch_item: 'HABApp.openhab.items.SwitchItem', invert_switch: bool = False, # default kw-args from the base class initial_value=None, - logger: typing.Optional[logging.Logger] = None, + logger: logging.Logger | None = None, auto_disable_after=None, auto_disable_func=None, - calc_value_func=None): + calc_value_func=None) -> None: """ :param name: Name of the mode @@ -68,5 +67,5 @@ def set_enabled(self, value: bool, only_on_change: bool = False): msg = 'Enabled is controlled through the switch item!' raise PermissionError(msg) - def __switch_changed(self, event): + def __switch_changed(self, event) -> None: self.__set_enable(event.value == ('ON' if not self.__invert_switch else 'OFF')) diff --git a/src/HABApp/util/multimode/mode_value.py b/src/HABApp/util/multimode/mode_value.py index 2a4894ba..579e781c 100644 --- a/src/HABApp/util/multimode/mode_value.py +++ b/src/HABApp/util/multimode/mode_value.py @@ -22,10 +22,10 @@ class ValueMode(BaseMode): """ def __init__(self, name: str, - initial_value=None, enabled: typing.Optional[bool] = None, enable_on_value: bool = True, - logger: typing.Optional[logging.Logger] = None, + initial_value=None, enabled: bool | None = None, enable_on_value: bool = True, + logger: logging.Logger | None = None, auto_disable_after=None, auto_disable_func=None, - calc_value_func=None): + calc_value_func=None) -> None: """ :param name: Name of the mode @@ -60,10 +60,10 @@ def __init__(self, name: str, self.__enable_on_value: bool = enable_on_value assert isinstance(auto_disable_after, timedelta) or auto_disable_after is None, type(auto_disable_after) - self.auto_disable_after: typing.Optional[timedelta] = auto_disable_after - self.auto_disable_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], bool]] = auto_disable_func + self.auto_disable_after: timedelta | None = auto_disable_after + self.auto_disable_func: typing.Callable[[typing.Any, typing.Any], bool] | None = auto_disable_func - self.calc_value_func: typing.Optional[typing.Callable[[typing.Any, typing.Any], typing.Any]] = calc_value_func + self.calc_value_func: typing.Callable[[typing.Any, typing.Any], typing.Any] | None = calc_value_func @property def value(self): @@ -77,7 +77,7 @@ def enabled(self) -> bool: # we don't use the setter here because of stupid inheritance # https://gist.github.com/Susensio/979259559e2bebcd0273f1a95d7c1e79 - def set_value(self, value, only_on_change: bool = False): + def set_value(self, value, only_on_change: bool = False) -> bool: """Set new value and recalculate overall value. If ``enable_on_value`` is set, setting a value will also enable the mode. @@ -172,5 +172,5 @@ def calculate_value(self, value_with_lower_priority: typing.Any) -> typing.Any: return self.__value return self.calc_value_func(value_with_lower_priority, self.__value) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {self.name} enabled: {self.__enabled}, value: {self.__value}>' diff --git a/src/HABApp/util/rate_limiter/limiter.py b/src/HABApp/util/rate_limiter/limiter.py index e8b8dc61..5addf464 100644 --- a/src/HABApp/util/rate_limiter/limiter.py +++ b/src/HABApp/util/rate_limiter/limiter.py @@ -1,7 +1,6 @@ from dataclasses import dataclass -from typing import Final, List, Literal, Tuple, Union, get_args +from typing import Final, Literal, TypeAlias, get_args -from HABApp.core.const.const import PYTHON_310 from HABApp.util.rate_limiter.limits import ( BaseRateLimit, FixedWindowElasticExpiryLimit, @@ -12,12 +11,6 @@ from HABApp.util.rate_limiter.parser import parse_limit -if PYTHON_310: - from typing import TypeAlias -else: - from typing_extensions import TypeAlias - - _LITERAL_LEAKY_BUCKET = Literal['leaky_bucket'] _LITERAL_FIXED_WINDOW_ELASTIC_EXPIRY = Literal['fixed_window_elastic_expiry'] @@ -31,9 +24,9 @@ def _check_arg(name: str, value, allow_0=False): class Limiter: - def __init__(self, name: str): + def __init__(self, name: str) -> None: self._name: Final = name - self._limits: Tuple[BaseRateLimit, ...] = () + self._limits: tuple[BaseRateLimit, ...] = () self._skips: int = 0 self._skips_total: int = 0 @@ -42,7 +35,7 @@ def total_skips(self) -> int: """A counter to track skips which can be manually reset""" return self._skips_total - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} {self._name:s}>' def add_limit(self, allowed: int, interval: int, *, @@ -161,4 +154,4 @@ def reset(self) -> 'Limiter': class LimiterInfo: skips: int #: How many entries were skipped in the active interval(s) total_skips: int #: How many entries were skipped in total - limits: List[Union[FixedWindowElasticExpiryLimitInfo, LeakyBucketLimitInfo]] #: Info for every limit + limits: list[FixedWindowElasticExpiryLimitInfo | LeakyBucketLimitInfo] #: Info for every limit diff --git a/src/HABApp/util/rate_limiter/limits/base.py b/src/HABApp/util/rate_limiter/limits/base.py index ef8ae87d..a7c058cd 100644 --- a/src/HABApp/util/rate_limiter/limits/base.py +++ b/src/HABApp/util/rate_limiter/limits/base.py @@ -14,7 +14,7 @@ def hits_remaining(self) -> int: class BaseRateLimit: - def __init__(self, allowed: int, interval: int, hits: int = 0): + def __init__(self, allowed: int, interval: int, hits: int = 0) -> None: super().__init__() assert allowed > 0, allowed assert interval > 0, interval @@ -29,7 +29,7 @@ def __init__(self, allowed: int, interval: int, hits: int = 0): def repr_text(self) -> str: return '' - def __repr__(self): + def __repr__(self) -> str: return ( f'<{self.__class__.__name__} hits={self.hits:d}/{self.allowed:d} interval={self.interval:d}s ' f'{self.repr_text():s}>' diff --git a/src/HABApp/util/rate_limiter/limits/fixed_window.py b/src/HABApp/util/rate_limiter/limits/fixed_window.py index 95cc4328..5aa46155 100644 --- a/src/HABApp/util/rate_limiter/limits/fixed_window.py +++ b/src/HABApp/util/rate_limiter/limits/fixed_window.py @@ -10,21 +10,21 @@ class FixedWindowElasticExpiryLimitInfo(BaseRateLimitInfo): class FixedWindowElasticExpiryLimit(BaseRateLimit): - def __init__(self, allowed: int, interval: int, hits: int = 0): + def __init__(self, allowed: int, interval: int, hits: int = 0) -> None: super().__init__(allowed, interval, hits) self.start: float = -1.0 self.stop: float = -1.0 - def repr_text(self): + def repr_text(self) -> str: return f'window={self.stop - self.start:.0f}s' - def do_test_allow(self): + def do_test_allow(self) -> None: if self.stop <= monotonic(): self.hits = 0 self.skips = 0 - def do_allow(self): + def do_allow(self) -> None: now = monotonic() if self.stop <= now: @@ -33,7 +33,7 @@ def do_allow(self): self.start = now self.stop = now + self.interval - def do_deny(self): + def do_deny(self) -> None: self.stop = monotonic() + self.interval def info(self) -> FixedWindowElasticExpiryLimitInfo: diff --git a/src/HABApp/util/rate_limiter/limits/leaky_bucket.py b/src/HABApp/util/rate_limiter/limits/leaky_bucket.py index fe39bb2e..650f6ac9 100644 --- a/src/HABApp/util/rate_limiter/limits/leaky_bucket.py +++ b/src/HABApp/util/rate_limiter/limits/leaky_bucket.py @@ -11,16 +11,16 @@ class LeakyBucketLimitInfo(BaseRateLimitInfo): class LeakyBucketLimit(BaseRateLimit): - def __init__(self, allowed: int, interval: int, hits: int = 0): + def __init__(self, allowed: int, interval: int, hits: int = 0) -> None: super().__init__(allowed, interval, hits) self.drop_interval: Final = interval / allowed self.next_drop: float = monotonic() + self.drop_interval - def repr_text(self): + def repr_text(self) -> str: return f'drop_interval={self.drop_interval:.1f}s' - def do_test_allow(self): + def do_test_allow(self) -> None: while self.next_drop <= monotonic(): self.hits -= 1 diff --git a/src/HABApp/util/rate_limiter/parser.py b/src/HABApp/util/rate_limiter/parser.py index b04e3514..c22ed1bd 100644 --- a/src/HABApp/util/rate_limiter/parser.py +++ b/src/HABApp/util/rate_limiter/parser.py @@ -1,5 +1,4 @@ import re -from typing import Tuple LIMIT_REGEX = re.compile( @@ -13,7 +12,7 @@ ) -def parse_limit(text: str) -> Tuple[int, int]: +def parse_limit(text: str) -> tuple[int, int]: if not isinstance(text, str) or not (m := LIMIT_REGEX.fullmatch(text)): msg = f'Invalid limit string: "{text:s}"' raise ValueError(msg) diff --git a/src/HABApp/util/statistics.py b/src/HABApp/util/statistics.py index 95e8749b..3e738619 100644 --- a/src/HABApp/util/statistics.py +++ b/src/HABApp/util/statistics.py @@ -14,7 +14,7 @@ class Statistics: :ivar last_value: last added value :ivar last_change: timestamp the last time a value was added """ - def __init__(self, max_age=None, max_samples=None): + def __init__(self, max_age=None, max_samples=None) -> None: """ :param max_age: Maximum age of values in seconds :param max_samples: Maximum amount of samples which will be kept @@ -48,7 +48,7 @@ def _remove_old(self): self.timestamps.popleft() self.values.popleft() - def update(self): + def update(self) -> None: """update values without adding a new value""" self._remove_old() @@ -74,7 +74,7 @@ def update(self): else: self.last_change = None - def add_value(self, value): + def add_value(self, value) -> None: """Add a new value and recalculate statistical values :param value: new value @@ -88,6 +88,6 @@ def add_value(self, value): self.update() - def __repr__(self): + def __repr__(self) -> str: return f'' diff --git a/src/HABApp/util/threshold.py b/src/HABApp/util/threshold.py index d036e8a5..69f1abd0 100644 --- a/src/HABApp/util/threshold.py +++ b/src/HABApp/util/threshold.py @@ -1,5 +1,5 @@ class Threshold: - def __init__(self, threshold1, threshold2): + def __init__(self, threshold1, threshold2) -> None: """This is a simple Schmitt Trigger implementation. If the value is > upper_threshold is_higher will return true. The return value will stay true until the value goes below the lower threshold. @@ -18,7 +18,7 @@ def is_on(self, value): self.check_value(value) return self.__threshold == self.lower_threshold - def is_off(self, value): + def is_off(self, value) -> bool: return not self.is_on(value) @property diff --git a/tests/conftest.py b/tests/conftest.py index c7fa3705..714afd8f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -35,7 +35,7 @@ def f(*args, **kwargs): @pytest.fixture(autouse=True) -def show_errors(monkeypatch): +def show_errors(monkeypatch) -> None: # Patch the wrapper so that we always raise the exception monkeypatch.setattr(HABApp.core.wrapper, 'ignore_exception', raise_err) monkeypatch.setattr(HABApp.core.wrapper, 'log_exception', raise_err) diff --git a/tests/helpers/event_bus.py b/tests/helpers/event_bus.py index 8acfb064..a008d545 100644 --- a/tests/helpers/event_bus.py +++ b/tests/helpers/event_bus.py @@ -10,16 +10,16 @@ class TestEventBus(EventBus): __test__ = False # prevents this class from being collected by pytest - def __init__(self): + def __init__(self) -> None: super().__init__() self.allow_errors = False self.errors = [] - def listen_events(self, name: str, cb, filter: EventFilterBase): + def listen_events(self, name: str, cb, filter: EventFilterBase) -> None: listener = EventBusListener(name, wrap_func(cb, name=f'TestFunc for {name}'), filter) self.add_listener(listener) - def post_event(self, topic: str, event: Any): + def post_event(self, topic: str, event: Any) -> None: if not self.allow_errors: if topic == TOPIC_ERRORS or isinstance(event, HABAppException): self.errors.append(event) diff --git a/tests/helpers/inspect/classes.py b/tests/helpers/inspect/classes.py index 61c768d0..fa304425 100644 --- a/tests/helpers/inspect/classes.py +++ b/tests/helpers/inspect/classes.py @@ -1,14 +1,15 @@ import inspect -from typing import Any, Dict, Iterable, Optional, Type, get_type_hints +from collections.abc import Iterable +from typing import Any, get_type_hints import pytest from .docstr import get_ivars_from_docstring -def check_class_annotations(cls: Type[object], - correct_hints: Optional[Dict[str, Any]] = None, - init_alias: Optional[Dict[str, str]] = None, init_missing: Iterable[str] = (), +def check_class_annotations(cls: type[object], + correct_hints: dict[str, Any] | None = None, + init_alias: dict[str, str] | None = None, init_missing: Iterable[str] = (), annotations_missing=False): """Ensure that the annotations match with the actual variables""" @@ -21,6 +22,10 @@ def check_class_annotations(cls: Type[object], docstr_vars = get_ivars_from_docstring(cls, correct_hints) init_vars = inspect.getfullargspec(cls).annotations + # If we return None we can just skip the annotation because it's most likely to be auto gernerated + if 'return' not in annotation_vars and 'return' in init_vars and init_vars['return'] is None: + del init_vars['return'] + # if we don't have annotations we can use the docstr vars if annotations_missing: assert not annotation_vars diff --git a/tests/helpers/inspect/docstr.py b/tests/helpers/inspect/docstr.py index 7d411b83..c0c6d8c6 100644 --- a/tests/helpers/inspect/docstr.py +++ b/tests/helpers/inspect/docstr.py @@ -1,7 +1,7 @@ import importlib import inspect import re -from typing import Any, Dict, Optional, Type +from typing import Any, Optional import pytest @@ -13,11 +13,11 @@ class IVarRedefinitionError(Exception): pass -class ExpectedHintNotFound(Exception): +class ExpectedHintNotFoundError(Exception): pass -def get_ivars_from_docstring(cls_obj: Type[object], correct_hints: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: +def get_ivars_from_docstring(cls_obj: type[object], correct_hints: dict[str, Any] | None = None) -> dict[str, Any]: if correct_hints is None: correct_hints = {} @@ -41,33 +41,33 @@ def get_ivars_from_docstring(cls_obj: Type[object], correct_hints: Optional[Dict ret[name] = hint else: if name in ret and ret[name] != hint: - raise IVarRedefinitionError( - f'Redefinition of type hint for {cls.__name__}.{name}: {ret[name]} != {hint}' - ) + msg = f'Redefinition of type hint for {cls.__name__}.{name}: {ret[name]} != {hint}' + raise IVarRedefinitionError(msg) ret[name] = hint # check that we have found the specified hint for name, correct_hint in correct_hints.items(): if name not in ret: - raise ExpectedHintNotFound(f'Expected hint not found for {cls_obj.__name__}.{name}: {correct_hint}') + msg = f'Expected hint not found for {cls_obj.__name__}.{name}: {correct_hint}' + raise ExpectedHintNotFoundError(msg) return ret -def test_ivar_redefinition(): +def test_ivar_redefinition() -> None: class Parent: """:ivar str a:""" class Child(Parent): - """:ivar Optional[Dict[str, str]] a:""" + """:ivar Optional[dict[str, str]] a:""" with pytest.raises(IVarRedefinitionError) as e: get_ivars_from_docstring(Child) assert str(e.value) == "Redefinition of type hint for Parent.a: " \ - "typing.Optional[typing.Dict[str, str]] != " + "typing.Optional[typing.dict[str, str]] != " - with pytest.raises(ExpectedHintNotFound) as e: - get_ivars_from_docstring(Child, {'a': Dict[str, str]}) - assert str(e.value) == 'Expected hint not found for Child.a: typing.Dict[str, str]' + with pytest.raises(ExpectedHintNotFoundError) as e: + get_ivars_from_docstring(Child, {'a': dict[str, str]}) + assert str(e.value) == 'Expected hint not found for Child.a: typing.dict[str, str]' - get_ivars_from_docstring(Child, {'a': Optional[Dict[str, str]]}) + get_ivars_from_docstring(Child, {'a': Optional[dict[str, str]]}) diff --git a/tests/helpers/inspect/habapp.py b/tests/helpers/inspect/habapp.py index e7dc6e95..ac22c3cb 100644 --- a/tests/helpers/inspect/habapp.py +++ b/tests/helpers/inspect/habapp.py @@ -2,14 +2,17 @@ import pkgutil from dataclasses import dataclass from inspect import getmembers -from types import ModuleType -from typing import Any, Iterable, List, Optional, Tuple, Type +from typing import TYPE_CHECKING, Any import HABApp +if TYPE_CHECKING: + from types import ModuleType + + def habapp_modules(): - modules: List[ModuleType] = [] + modules: list[ModuleType] = [] module_info = pkgutil.walk_packages(HABApp.__path__, HABApp.__name__ + '.') for package in sorted(module_info, key=lambda x: x.name): modules.append(importlib.import_module(package.name)) @@ -23,26 +26,19 @@ class FoundObj: obj: Any -def find_in_modules(objs: Optional[Iterable[Any]] = None, - instances: Optional[Tuple[Type[object], ...]] = None): - - assert objs or instances - +def find_in_modules(instances: tuple[type[object], ...] | None = None, + subclasses: tuple[type[object], ...] | None = None) -> list[FoundObj]: predicates = [] - if objs: - def is_obj(x): - for obj in objs: - if obj is x: - return True - return False - predicates.append(is_obj) if instances: predicates.append(lambda x: isinstance(x, instances)) + if subclasses: + predicates.append(lambda x: issubclass(x, subclasses)) - ret: List[FoundObj] = [] + ret: list[FoundObj] = [] for module in habapp_modules(): for name, obj in getmembers(module, predicate=lambda x: any(p(x) for p in predicates)): ret.append(FoundObj(name, obj)) + assert ret return ret diff --git a/tests/helpers/inspect/module.py b/tests/helpers/inspect/module.py index 4c3002c6..eab187cf 100644 --- a/tests/helpers/inspect/module.py +++ b/tests/helpers/inspect/module.py @@ -2,13 +2,13 @@ import inspect import sys import typing -from typing import Callable, Iterable, List, Optional, Tuple, Union +from collections.abc import Callable, Iterable -def get_module_classes(module_name: str, /, exclude: Optional[Iterable[Union[str, type]]] = None, include_imported=True, - subclass: Union[None, type, Tuple[type, ...]] = None, include_subclass=True): +def get_module_classes(module_name: str, /, exclude: Iterable[str | type] | None = None, include_imported=True, + subclass: None | type | tuple[type, ...] = None, include_subclass=True): - filters: List[Callable[[type], bool]] = [ + filters: list[Callable[[type], bool]] = [ lambda x: inspect.isclass(x), # exclude typing classes by default (e.g. Any, Union) diff --git a/tests/helpers/inspect/signature.py b/tests/helpers/inspect/signature.py index eca3b282..ca6656d7 100644 --- a/tests/helpers/inspect/signature.py +++ b/tests/helpers/inspect/signature.py @@ -1,10 +1,9 @@ import inspect -from typing import Optional import pytest -def assert_same_signature(func_a, func_b): +def assert_same_signature(func_a, func_b) -> bool: sig_a = inspect.signature(func_a) sig_b = inspect.signature(func_b) assert sig_a == sig_b, f'\n {sig_a}\n {sig_b}\n' @@ -16,17 +15,17 @@ def assert_same_signature(func_a, func_b): return True -def test_assert_same_signature(): - def func1(a: int, b: Optional[str] = None) -> float: +def test_assert_same_signature() -> None: + def func1(a: int, b: str | None = None) -> float: """Doc1""" - def func1_no_ret(a: int, b: Optional[str] = None): + def func1_no_ret(a: int, b: str | None = None) -> None: """Doc1""" - def func1_diff_args(a: int, b: str = None) -> float: + def func1_diff_args(a: int, b: str = None) -> float: # noqa: RUF013 """Doc1""" - def func1_diff_doc(a: int, b: Optional[str] = None) -> float: + def func1_diff_doc(a: int, b: str | None = None) -> float: """Doc2""" with pytest.raises(AssertionError): diff --git a/tests/helpers/log/log_collector.py b/tests/helpers/log/log_collector.py index 2796b4cd..c881a8e0 100644 --- a/tests/helpers/log/log_collector.py +++ b/tests/helpers/log/log_collector.py @@ -1,7 +1,8 @@ import logging +from collections.abc import Callable, Iterable from operator import eq as eq_func from operator import ge as ge_func -from typing import Any, Callable, Dict, Final, Iterable, List, Optional, Union +from typing import Any, Final import pytest from pytest import LogCaptureFixture @@ -15,7 +16,7 @@ class LogCollector: - def __init__(self, caplog: LogCaptureFixture, level: int = logging.WARNING): + def __init__(self, caplog: LogCaptureFixture, level: int = logging.WARNING) -> None: self.caplog: Final = caplog self.level_nr: int = level @@ -23,12 +24,12 @@ def __init__(self, caplog: LogCaptureFixture, level: int = logging.WARNING): self.phases: Iterable[str] = ALL_PYTEST_PHASES - self.rec_expected: List[LogEntryMatcherBase] = [] - self.rec_ignored: List[LogEntryMatcherBase] = [] + self.rec_expected: list[LogEntryMatcherBase] = [] + self.rec_ignored: list[LogEntryMatcherBase] = [] # results - self.res_records: List[SimpleLogRecord] = [] - self.res_indent: Dict[str, int] = {} + self.res_records: list[SimpleLogRecord] = [] + self.res_indent: dict[str, int] = {} def is_expected_record(self, rec: SimpleLogRecord) -> bool: for expected in self.rec_expected: @@ -69,15 +70,15 @@ def set_phases(self, *phases: str) -> Self: return self def add_expected(self, - name: Union[Iterable[str], Optional[str]], - level: Union[Iterable[Union[str, int]], Union[str, int]], - msg: Union[Iterable[str], str]): + name: Iterable[str] | str | None, + level: Iterable[str | int] | str | int, + msg: Iterable[str] | str) -> None: self.rec_expected.extend(create_matcher(name, level, msg)) def add_ignored(self, - name: Union[Iterable[str], Optional[str]], - level: Union[Iterable[Union[str, int]], Union[str, int]], - msg: Union[Iterable[str], str]): + name: Iterable[str] | str | None, + level: Iterable[str | int] | str | int, + msg: Iterable[str] | str) -> None: self.rec_ignored.extend(create_matcher(name, level, msg)) def update(self) -> Self: @@ -112,7 +113,7 @@ def update(self) -> Self: return self - def get_messages(self) -> List[str]: + def get_messages(self) -> list[str]: return [ f'{"ok " if self.is_expected_record(rec) else " " }' f'[{rec.name:>{self.res_indent["name"]:d}s}] | ' @@ -121,7 +122,7 @@ def get_messages(self) -> List[str]: for rec in self.res_records ] - def assert_ok(self): + def assert_ok(self) -> None: self.update() missing = [] @@ -141,7 +142,7 @@ def assert_ok(self): pytest.fail(reason='Error in log:\n' + '\n'.join(self.get_messages())) -def test_cap_warning(test_logs): +def test_cap_warning(test_logs) -> None: logging.getLogger('TEST').warning('WARNING') diff --git a/tests/helpers/log/log_matcher.py b/tests/helpers/log/log_matcher.py index fdd2d223..2a8eac5e 100644 --- a/tests/helpers/log/log_matcher.py +++ b/tests/helpers/log/log_matcher.py @@ -1,7 +1,8 @@ import logging import os import re -from typing import Final, Iterable, List, Optional, Union +from collections.abc import Iterable +from typing import Final from .log_utils import SimpleLogRecord, get_log_level_name, get_log_level_no @@ -10,7 +11,7 @@ class LogEntryMatcherBase: def matches(self, r: SimpleLogRecord): raise NotImplementedError() - def found(self, recs: Iterable[SimpleLogRecord]): + def found(self, recs: Iterable[SimpleLogRecord]) -> bool: for r in recs: if self.matches(r): return True @@ -18,14 +19,14 @@ def found(self, recs: Iterable[SimpleLogRecord]): class LogLevelMatcher(LogEntryMatcherBase): - def __init__(self, level: Union[int, str]): + def __init__(self, level: int | str) -> None: self.level: Final = get_log_level_no(level) self.level_name: Final = get_log_level_name(self.level) def matches(self, r: SimpleLogRecord): return r.level == self.level - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} level={self.level_name}>' @@ -33,11 +34,11 @@ def __repr__(self): class AsyncDebugWarningMatcher(LogEntryMatcherBase): - def __init__(self): + def __init__(self) -> None: self.duration = re.compile(r' took (\d+.\d+) seconds$') self.coro = re.compile(r' coro=<(\w+)\(') - def matches(self, r: SimpleLogRecord): + def matches(self, r: SimpleLogRecord) -> bool: if r.name != 'asyncio' or r.level != logging.WARNING or 'Executing <' not in r.msg: return False @@ -60,12 +61,12 @@ def matches(self, r: SimpleLogRecord): return False - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__}>' class LogEntryMatcher(LogEntryMatcherBase): - def __init__(self, name: Optional[str], level: Union[int, str], msg: str): + def __init__(self, name: str | None, level: int | str, msg: str) -> None: self.name: Final = name self.level: Final = get_log_level_no(level) self.level_name: Final = get_log_level_name(self.level) @@ -76,23 +77,23 @@ def matches(self, r: SimpleLogRecord): (self.level is None or self.level == r.level) and \ (self.msg is None or self.msg == r.msg) - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} name={self.name} level={self.level_name} msg={self.msg}>' class ConsecutiveMatcher(LogEntryMatcherBase): - def __init__(self, matcher: Iterable[LogEntryMatcherBase]): + def __init__(self, matcher: Iterable[LogEntryMatcherBase]) -> None: self.matchers: Final = tuple(matcher) - self.rec_ok: List[SimpleLogRecord] = [] + self.rec_ok: list[SimpleLogRecord] = [] - def matches(self, r: SimpleLogRecord): + def matches(self, r: SimpleLogRecord) -> bool: if r in self.rec_ok: return True self.rec_ok.clear() recs = [] - current: Optional[SimpleLogRecord] = None + current: SimpleLogRecord | None = None for m in self.matchers: if current is not None: current = current.next @@ -109,14 +110,14 @@ def matches(self, r: SimpleLogRecord): self.rec_ok = recs return True - def __repr__(self): + def __repr__(self) -> str: return f'<{self.__class__.__name__} matchers={self.matchers}>' -def create_matcher(name: Union[Iterable[str], Union[str, None]], - level: Union[Iterable[Union[str, int]], Union[str, int]], - msg: Union[Iterable[str], str]) -> List[LogEntryMatcher]: +def create_matcher(name: Iterable[str] | str | None, + level: Iterable[str | int] | str | int, + msg: Iterable[str] | str) -> list[LogEntryMatcher]: names = [name] if isinstance(name, str) or name is None else name levels = [level] if isinstance(level, (str, int)) else level diff --git a/tests/helpers/log/log_utils.py b/tests/helpers/log/log_utils.py index c3ad2637..4bad51eb 100644 --- a/tests/helpers/log/log_utils.py +++ b/tests/helpers/log/log_utils.py @@ -1,10 +1,10 @@ import logging from dataclasses import dataclass from logging import LogRecord -from typing import Optional, Union +from typing import Optional -def get_log_level_no(level: Union[str, int]) -> int: +def get_log_level_no(level: str | int) -> int: if isinstance(level, int): return level diff --git a/tests/helpers/mock_file.py b/tests/helpers/mock_file.py index 4a96db5f..c53d5b41 100644 --- a/tests/helpers/mock_file.py +++ b/tests/helpers/mock_file.py @@ -1,6 +1,7 @@ +from collections.abc import Callable from io import StringIO from pathlib import Path, PurePath -from typing import Any, Callable, TextIO, Union +from typing import Any, TextIO from warnings import warn @@ -18,14 +19,14 @@ def close(self) -> None: class MockFile: - def __init__(self, path: str, data: str = ''): + def __init__(self, path: str, data: str = '') -> None: super().__init__() self.path = Path(path) self.data: str = data self.warn_on_delete = True - def __set_data(self, val: str): + def __set_data(self, val: str) -> None: self.data = val def is_file(self) -> bool: @@ -34,12 +35,12 @@ def is_file(self) -> bool: def open(self, *args, **kwargs) -> TextIO: return MyStringIO(self.__set_data, self.data) - def rename(self, target: Union[str, PurePath]): + def rename(self, target: str | PurePath): if self.warn_on_delete: warn(f'Not supported for {self.__class__.__name__}!', UserWarning, stacklevel=2) return None - def replace(self, target: Union[str, PurePath]) -> None: + def replace(self, target: str | PurePath) -> None: if self.warn_on_delete: warn(f'Not supported for {self.__class__.__name__}!', UserWarning, stacklevel=2) return None diff --git a/tests/helpers/mock_monotonic.py b/tests/helpers/mock_monotonic.py index c957faff..d0376858 100644 --- a/tests/helpers/mock_monotonic.py +++ b/tests/helpers/mock_monotonic.py @@ -1,5 +1,5 @@ class MockedMonotonic: - def __init__(self): + def __init__(self) -> None: self.time = 0 def get_time(self): diff --git a/tests/helpers/parent_rule.py b/tests/helpers/parent_rule.py index db9a475d..01be6efd 100644 --- a/tests/helpers/parent_rule.py +++ b/tests/helpers/parent_rule.py @@ -6,7 +6,7 @@ class DummyRule(ContextProvidingObj): - def __init__(self): + def __init__(self) -> None: super().__init__(context=HABApp.rule_ctx.HABAppRuleContext(self)) self.rule_name = 'DummyRule' diff --git a/tests/helpers/sync_worker.py b/tests/helpers/sync_worker.py index 9a849cd5..6cf50d78 100644 --- a/tests/helpers/sync_worker.py +++ b/tests/helpers/sync_worker.py @@ -6,12 +6,12 @@ class SyncTestWorker: @staticmethod - def submit(callback, *args, **kwargs): + def submit(callback, *args, **kwargs) -> None: callback(*args, **kwargs) @pytest.fixture() -def sync_worker(monkeypatch): +def sync_worker(monkeypatch) -> None: monkeypatch.setattr(wrapped_thread, 'POOL', SyncTestWorker()) assert not hasattr(wrapper_module, 'SYNC_CLS') diff --git a/tests/helpers/traceback.py b/tests/helpers/traceback.py index 2504f164..b3d51dcb 100644 --- a/tests/helpers/traceback.py +++ b/tests/helpers/traceback.py @@ -15,7 +15,7 @@ def remove_dyn_parts_from_traceback(traceback: str) -> str: return traceback -def test_remove_dyn_parts_from_traceback(): +def test_remove_dyn_parts_from_traceback() -> None: traceback = ''' File "C:\\My\\Folder\\HABApp\\tests\\test_core\\test_lib\\test_format_traceback.py", line 19 in exec_func diff --git a/tests/rule_runner/rule_runner.py b/tests/rule_runner/rule_runner.py index 1fe206b9..6ef25c65 100644 --- a/tests/rule_runner/rule_runner.py +++ b/tests/rule_runner/rule_runner.py @@ -1,11 +1,14 @@ -from typing import List +import asyncio +from types import TracebackType +from astral import Observer +from eascheduler.producers import prod_sun as prod_sun_module from pytest import MonkeyPatch import HABApp import HABApp.core.lib.exceptions.format import HABApp.rule.rule as rule_module -import HABApp.rule.scheduler.habappschedulerview as ha_sched +import HABApp.rule.scheduler.job_builder as job_builder_module from HABApp.core.asyncio import async_context from HABApp.core.internals import EventBus, ItemRegistry, setup_internals from HABApp.core.internals.proxy import ConstProxyObj @@ -23,51 +26,66 @@ def suggest_rule_name(obj: object) -> str: class SyncScheduler: ALL = [] - def __init__(self): + def __init__(self, event_loop=None, enabled=True) -> None: SyncScheduler.ALL.append(self) self.jobs = [] - def add_job(self, job): + def add_job(self, job) -> None: self.jobs.append(job) - def remove_job(self, job): - self.jobs.remove(job) + def update_job(self, job) -> None: + self.remove_job(job) + self.add_job(job) - def cancel_all(self): + def remove_job(self, job) -> None: + if job in self.jobs: + self.jobs.remove(job) + + def remove_all(self) -> None: self.jobs.clear() + def set_enabled(self, enabled: bool) -> None: # noqa: FBT001 + pass + class DummyRuntime(Runtime): - def __init__(self): + def __init__(self) -> None: pass -def raising_fallback_format(e: Exception, existing_traceback: List[str]) -> List[str]: +def raising_fallback_format(e: Exception, existing_traceback: list[str]) -> list[str]: traceback = fallback_format(e, existing_traceback) traceback = traceback raise class SimpleRuleRunner: - def __init__(self): + def __init__(self) -> None: self.loaded_rules = [] self.monkeypatch = MonkeyPatch() self.restore = [] + self.ctx = asyncio.Future() - def submit(self, callback, *args, **kwargs): + def submit(self, callback, *args, **kwargs) -> None: # This executes the callback so we can not ignore exceptions callback(*args, **kwargs) - def set_up(self): + def set_up(self) -> None: # ensure that we call setup only once! assert isinstance(HABApp.core.Items, ConstProxyObj) assert isinstance(HABApp.core.EventBus, ConstProxyObj) + # prevent we're calling from asyncio - this works because we don't use threads + self.ctx = async_context.set('Rule Runner') + ir = ItemRegistry() eb = EventBus() self.restore = setup_internals(ir, eb, final=False) + # Scheduler + self.monkeypatch.setattr(prod_sun_module, 'OBSERVER', Observer(52.51870523376821, 13.376072914752532, 10)) + # Overwrite self.monkeypatch.setattr(HABApp.core, 'EventBus', eb) self.monkeypatch.setattr(HABApp.core, 'Items', ir) @@ -84,10 +102,9 @@ def set_up(self): self.monkeypatch.setattr(HABApp.core.lib.exceptions.format, 'fallback_format', raising_fallback_format) # patch scheduler, so we run synchronous - self.monkeypatch.setattr(ha_sched, '_HABAppScheduler', SyncScheduler) + self.monkeypatch.setattr(job_builder_module, 'AsyncHABAppScheduler', SyncScheduler) - def tear_down(self): - ctx = async_context.set('Tear down test') + def tear_down(self) -> None: for rule in self.loaded_rules: rule._habapp_ctx.unload_rule() @@ -95,20 +112,23 @@ def tear_down(self): # restore patched self.monkeypatch.undo() - async_context.reset(ctx) + + # restore async context + async_context.reset(self.ctx) + self.ctx = None for r in self.restore: r.restore() - def process_events(self): + def process_events(self) -> None: for s in SyncScheduler.ALL: for job in s.jobs: - job._func.execute() + job.executor.execute() - def __enter__(self): + def __enter__(self) -> None: self.set_up() - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None) -> bool: self.tear_down() # do not supress exception return False diff --git a/tests/rule_runner/test_rule_runner.py b/tests/rule_runner/test_rule_runner.py index 64e07f74..1bfb7275 100644 --- a/tests/rule_runner/test_rule_runner.py +++ b/tests/rule_runner/test_rule_runner.py @@ -1,39 +1,64 @@ +import subprocess +import sys + import pytest import HABApp.core.lib.exceptions.format -@pytest.mark.no_internals() -def test_doc_run(): - calls = [] +@pytest.mark.no_internals +def test_doc_run() -> None: + code = ''' +calls = [] - from tests import SimpleRuleRunner - runner = SimpleRuleRunner() - runner.set_up() - import HABApp +from tests import SimpleRuleRunner +runner = SimpleRuleRunner() +runner.set_up() +import HABApp - class MyFirstRule(HABApp.Rule): - def __init__(self, my_parameter): - super().__init__() - self.param = my_parameter - self.run.soon(self.say_something) - - def say_something(self): - calls.append(self.param) - - # This is normal python code, so you can create Rule instances as you like - for i in range(2): - MyFirstRule(i) - for t in ['Text 1', 'Text 2']: - MyFirstRule(t) - runner.process_events() - runner.tear_down() +class MyFirstRule(HABApp.Rule): + def __init__(self, my_parameter): + super().__init__() + self.param = my_parameter + self.run.soon(self.do_something) - assert len(calls) == 4 + def do_something(self): + calls.append(self.param) +# This is normal python code, so you can create Rule instances as you like +for i in range(2): + MyFirstRule(i) +for t in ['Text 1', 'Text 2']: + MyFirstRule(t) +runner.process_events() +runner.tear_down() -@pytest.mark.no_internals() -def test_doc_run_exception(monkeypatch): +assert len(calls) == 4 +''' + + proc = subprocess.Popen( # noqa: S603 + [sys.executable, '-c', code.encode('utf-8')], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True + ) + + err = 1 + + try: + stdout, stderr = proc.communicate(timeout=5) + err = 0 + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + + if err: + print('') + print('Standard Output:', stdout) + print('Standard Error:', stderr) + pytest.fail('Error') + + +@pytest.mark.no_internals +def test_doc_run_exception(monkeypatch) -> None: """Check that the RuleRunner propagates exceptions which happen during exception formatting""" class MyException(Exception): @@ -49,11 +74,16 @@ def err(*args, **kwargs): runner.set_up() class MyFirstRule(HABApp.Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() - self.run.soon(self.say_something) + self.run.soon(self.do_something) + + j = self.run.at(self.run.trigger.sunrise(), lambda: 1/0) + j.pause() + j.resume() + j.cancel() - def say_something(self): + def do_something(self) -> None: 1 / 0 MyFirstRule() diff --git a/tests/test_all/test_items.py b/tests/test_all/test_items.py index cc016790..a82caa4e 100644 --- a/tests/test_all/test_items.py +++ b/tests/test_all/test_items.py @@ -1,6 +1,6 @@ import pytest -from HABApp.core.items import HINT_TYPE_ITEM_OBJ, BaseValueItem +from HABApp.core.items import BaseValueItem from HABApp.mqtt.items import MqttBaseItem from tests.helpers.inspect import get_module_classes @@ -24,7 +24,7 @@ def get_item_classes(skip=()): @pytest.mark.parametrize('item_cls, default', get_item_classes()) -def test_create_item(item_cls: HINT_TYPE_ITEM_OBJ, default): +def test_create_item(item_cls: type[BaseValueItem], default) -> None: # test normal create item = item_cls('item_name') @@ -39,7 +39,7 @@ def test_create_item(item_cls: HINT_TYPE_ITEM_OBJ, default): @pytest.mark.parametrize( 'item_cls, default', get_item_classes(skip=('openhab', 'MqttPairItem'),)) -def test_get_create_item(item_cls: HINT_TYPE_ITEM_OBJ, default): +def test_get_create_item(item_cls: type[BaseValueItem], default) -> None: # test normal create item = item_cls.get_create_item('item_name') diff --git a/tests/test_cmd_args.py b/tests/test_cmd_args.py index 74a5af99..aac96b09 100644 --- a/tests/test_cmd_args.py +++ b/tests/test_cmd_args.py @@ -6,12 +6,12 @@ from HABApp import __cmd_args__ -def test_get_uptime(): +def test_get_uptime() -> None: __cmd_args__.get_uptime() @pytest.mark.parametrize('arg_name', ('-wos', '--wait_os_uptime')) -def test_cmd_wait_uptime(monkeypatch, arg_name): +def test_cmd_wait_uptime(monkeypatch, arg_name) -> None: td = timedelta(days=110 * 365) m = Mock() diff --git a/tests/test_config/test_config.py b/tests/test_config/test_config.py new file mode 100644 index 00000000..7ff98328 --- /dev/null +++ b/tests/test_config/test_config.py @@ -0,0 +1,89 @@ +import logging +import re + +from easyconfig.yaml import yaml_safe +from tests.helpers import LogCollector + +from HABApp import CONFIG +from HABApp.config.models.mqtt import Subscribe + + +def test_default_file() -> None: + msg = re.sub(r'identifier:\s+HABApp-\w{13}', f'identifier: HABApp-{"TestFile":13s}', CONFIG.generate_default_yaml()) + assert '\n' + msg == ''' +directories: + logging: log # Folder where the logs will be written to + rules: rules # Folder from which the rule files will be loaded + param: params # Folder from which the parameter files will be loaded + config: config # Folder from which configuration files (e.g. for textual thing configuration) will be loaded + lib: lib # Folder where additional libraries can be placed +location: + latitude: 0.0 + longitude: 0.0 + elevation: 0.0 + country: '' # ISO 3166-1 Alpha-2 country code + subdivision: '' # The subdivision (e.g. state or province) as a ISO 3166-2 code or its alias +mqtt: + connection: + identifier: HABApp-TestFile # Identifier that is used to uniquely identify this client on the mqtt broker. + host: '' # Connect to this host. Empty string ("") disables the connection. + port: 1883 + user: '' + password: '' + tls: + enabled: true # Enable TLS for the connection + ca cert: . # Path to a CA certificate that will be treated as trusted + insecure: false # Validate server hostname in server certificate + subscribe: + qos: 0 # Default QoS for subscribing + topics: + - '#' + - topic/with/default/qos + - - topic/with/qos + - 1 + publish: + qos: 0 # Default QoS when publishing values + retain: false # Default retain flag when publishing values + general: + listen_only: false # If True HABApp does not publish any value to the broker +openhab: + connection: + url: http://localhost:8080 # Connect to this url. Empty string ("") disables the connection. + user: '' + password: '' + verify_ssl: true # Check certificates when using https + general: + listen_only: false # If True HABApp does not change anything on the openHAB instance. + wait_for_openhab: true # If True HABApp will wait for a successful openHAB connection before loading any rules on startup + ping: + enabled: true # If enabled the configured item will show how long it takes to send an update from HABApp and get the updated value back from openHAB in milliseconds + item: HABApp_Ping # Name of the Numberitem + interval: 10 # Seconds between two pings +''' + + +def test_migrate(test_logs: LogCollector) -> None: + text = ( + '\n' + ' subscribe:\n' + ' qos: 0 # Default QoS for subscribing\n' + ' topics:\n' + " - - '#'\n" + ' - \n' + ' publish:\n' + ) + + obj = yaml_safe.load(text) + Subscribe.model_validate(obj['subscribe']) + + test_logs.add_expected( + 'HABApp.Config', logging.WARNING, + [ + 'Empty QoS is not longer allowed for subscribing to topics.', + 'Specify QOS or remove empty entry, e.g from', + ' - - #', + ' - ', + 'to', + ' - #', + ] + ) diff --git a/tests/test_config/test_logging.py b/tests/test_config/test_logging.py new file mode 100644 index 00000000..c13ce0f9 --- /dev/null +++ b/tests/test_config/test_logging.py @@ -0,0 +1,84 @@ +import logging + +from HABApp.config.logging import get_logging_dict +from HABApp.config.logging.config import inject_queue_handler, remove_memory_handler_from_cfg + + +log = logging.getLogger('test') + + +def test_add_version(test_logs) -> None: + assert get_logging_dict({}, log)[0] == {'version': 1} + + +def get_unpack(log_dict): + value = get_logging_dict(log_dict, log)[0] + value.pop('version') + return value + + +def test_fix_old_logger(test_logs) -> None: + cfg = {'handlers': {'my_handler': {'class': 'HABApp.core.lib.handler.MidnightRotatingFileHandler'}}} + dst = {'handlers': {'my_handler': {'class': 'HABApp.config.logging.MidnightRotatingFileHandler'}}} + test_logs.add_expected( + 'test', logging.WARNING, + 'Replaced class for handler "my_handler" with HABApp.config.logging.MidnightRotatingFileHandler' + ) + + assert get_unpack(cfg) == dst + + +def test_remove_memory_handler(test_logs) -> None: + cfg = { + 'handlers': { + 'BufferEventFile': {'class': 'logging.handlers.MemoryHandler', 'target': 'EventFile'}, + 'EventFile': {'class': 'logging.handlers.RotatingFileHandler', 'filename': 'events.log'}, + }, + 'loggers': { + 'HABApp.EventBus': {'handlers': ['BufferEventFile'], 'level': 'DEBUG', 'propagate': False} + } + } + + dst = { + 'handlers': { + 'EventFile': {'class': 'logging.handlers.RotatingFileHandler', 'filename': 'events.log'}, + }, + 'loggers': { + 'HABApp.EventBus': {'handlers': ['EventFile'], 'level': 'DEBUG', 'propagate': False} + } + } + + remove_memory_handler_from_cfg(cfg['handlers'], cfg['loggers'], log) + assert cfg == dst + + test_logs.add_expected( + 'test', logging.ERROR, + '"logging.handlers.MemoryHandler" is no longer required. Please remove from config (BufferEventFile)!' + ) + test_logs.add_expected('test', logging.WARNING, 'Removed BufferEventFile from handlers') + test_logs.add_expected('test', logging.WARNING, 'Replaced BufferEventFile with EventFile for logger HABApp.EventBus') + + +def test_inject_queue_handler() -> None: + cfg = { + 'handlers': { + 'EventFile': {'class': 'logging.handlers.RotatingFileHandler', 'filename': 'events.log'}, + }, + 'loggers': { + 'HABApp.EventBus': {'handlers': ['BufferEventFile'], 'level': 'DEBUG', 'propagate': False} + } + } + + handlers = inject_queue_handler(cfg['handlers'], cfg['loggers'], log) + + dst = { + 'handlers': { + 'EventFile': {'class': 'logging.handlers.RotatingFileHandler', 'filename': 'events.log'}, + 'HABAppQueue_BufferEventFile': {'class': 'logging.handlers.QueueHandler', 'queue': handlers[0]._queue}, + }, + 'loggers': { + 'HABApp.EventBus': {'handlers': ['HABAppQueue_BufferEventFile'], 'level': 'DEBUG', 'propagate': False} + } + } + + assert cfg == dst diff --git a/tests/test_config/test_platform.py b/tests/test_config/test_platform.py index 3c4fd637..df562536 100644 --- a/tests/test_config/test_platform.py +++ b/tests/test_config/test_platform.py @@ -5,15 +5,15 @@ from HABApp.config.platform_defaults import get_log_folder -def test_defaults(): +def test_defaults() -> None: assert None is get_log_folder() assert Path('/log') == get_log_folder(Path('/log')) -def test_valid_yml(monkeypatch): +def test_valid_yml(monkeypatch) -> None: """ensure we create valid yml files""" - def ensure_key(key, obj): + def ensure_key(key, obj) -> None: if isinstance(obj, dict): for k, v in obj.items(): ensure_key(k, v) diff --git a/tests/test_core/test_connections.py b/tests/test_core/test_connections.py index be93ae2e..6c70cafd 100644 --- a/tests/test_core/test_connections.py +++ b/tests/test_core/test_connections.py @@ -1,4 +1,3 @@ -from typing import List from unittest.mock import Mock import pytest @@ -7,10 +6,10 @@ from HABApp.core.connections.status_transitions import ConnectionStatus, StatusTransitions -def test_transitions(): +def test_transitions() -> None: status = StatusTransitions() - def get_flow() -> List[str]: + def get_flow() -> list[str]: ret = [] while add := status.advance_status(): ret.append(add.value) @@ -44,24 +43,24 @@ def get_flow() -> List[str]: assert get_flow() == ['DISCONNECTED', 'OFFLINE', 'SHUTDOWN'] -async def test_plugin_callback(): +async def test_plugin_callback() -> None: sentinel = object() mock_connected = Mock() mock_setup = Mock() class TestPlugin(BaseConnectionPlugin): - def __init__(self): + def __init__(self) -> None: super().__init__('asdf') - async def on_connected(self, context): + async def on_connected(self, context) -> None: assert context is sentinel mock_connected() - async def on_disconnected(self): + async def on_disconnected(self) -> None: pass - async def on_setup(self, context, connection): + async def on_setup(self, context, connection) -> None: assert context is sentinel assert connection is b mock_setup() @@ -88,29 +87,29 @@ async def on_setup(self, context, connection): mock_setup.assert_called_once() -def test_coro_inspection(): +def test_coro_inspection() -> None: p = BaseConnectionPlugin('test') # ------------------------------------------------------------------------- # Check args # - async def coro(): + async def coro() -> None: pass assert PluginCallbackHandler._get_coro_kwargs(p, coro) == () - async def coro(connection): + async def coro(connection) -> None: pass assert PluginCallbackHandler._get_coro_kwargs(p, coro) == ('connection', ) - async def coro(connection, context): + async def coro(connection, context) -> None: pass assert PluginCallbackHandler._get_coro_kwargs(p, coro) == ('connection', 'context') # typo in definition - async def coro(connection, contrxt): + async def coro(connection, contrxt) -> None: pass with pytest.raises(ValueError) as e: diff --git a/tests/test_core/test_context.py b/tests/test_core/test_context.py index 596a06a5..a799ef53 100644 --- a/tests/test_core/test_context.py +++ b/tests/test_core/test_context.py @@ -3,7 +3,7 @@ from HABApp.core.asyncio import AsyncContextError, async_context -async def test_error_msg(): +async def test_error_msg() -> None: def my_sync_func(): if async_context.get(None) is not None: diff --git a/tests/test_core/test_event_bus.py b/tests/test_core/test_event_bus.py index 70be987f..50018544 100644 --- a/tests/test_core/test_event_bus.py +++ b/tests/test_core/test_event_bus.py @@ -9,7 +9,7 @@ class TestEvent: pass -def test_repr(sync_worker): +def test_repr(sync_worker) -> None: f = wrap_func(lambda x: x) listener = EventBusListener('test_name', f, NoEventFilter()) @@ -19,27 +19,41 @@ def test_repr(sync_worker): assert listener.describe() == '"test_name" (filter=EventFilter(type=ValueUpdateEvent, value=test1))' -def test_str_event(sync_worker): - event_history = [] +def test_str_event(sync_worker) -> None: + """Test simple event and add/remove""" + event_history1 = [] + event_history2 = [] eb = EventBus() - def append_event(event): - event_history.append(event) - func = wrap_func(append_event) + def append_event(event) -> None: + event_history1.append(event) + func1 = wrap_func(append_event) - listener = EventBusListener('str_test', func, NoEventFilter()) - eb.add_listener(listener) + def append_event2(event) -> None: + event_history2.append(event) + func2 = wrap_func(append_event2) + + listener1 = EventBusListener('str_test', func1, NoEventFilter()) + eb.add_listener(listener1) + listener2 = EventBusListener('str_test', func2, NoEventFilter()) + eb.add_listener(listener2) eb.post_event('str_test', 'str_event') - assert event_history == ['str_event'] + assert event_history1 == ['str_event'] + assert event_history2 == ['str_event'] + + eb.remove_listener(listener1) + eb.post_event('str_test', 'str_event_2') + assert event_history1 == ['str_event'] + assert event_history2 == ['str_event', 'str_event_2'] -def test_multiple_events(sync_worker): +def test_multiple_events(sync_worker) -> None: event_history = [] eb = EventBus() target = ['str_event', TestEvent(), 'str_event2'] - def append_event(event): + def append_event(event) -> None: event_history.append(event) listener = EventBusListener( @@ -53,7 +67,7 @@ def append_event(event): assert event_history == target -def test_complex_event_unpack(sync_worker): +def test_complex_event_unpack(sync_worker) -> None: """Test that the ComplexEventValue get properly unpacked""" m = MagicMock() assert not m.called diff --git a/tests/test_core/test_events/test_core_filters.py b/tests/test_core/test_events/test_core_filters.py index 828b66d5..d77b75d4 100644 --- a/tests/test_core/test_events/test_core_filters.py +++ b/tests/test_core/test_events/test_core_filters.py @@ -14,14 +14,14 @@ ) -def test_class_annotations(): +def test_class_annotations() -> None: """EventFilter relies on the class annotations, so we test that every event has those""" for cls in get_module_classes('HABApp.core.events.events', ('ComplexEventValue', 'AllEvents')).values(): check_class_annotations(cls) -def test_repr(): +def test_repr() -> None: assert NoEventFilter().describe() == 'NoEventFilter()' f = EventFilter(ValueUpdateEvent, value=1) @@ -46,14 +46,14 @@ def test_repr(): assert f.describe() == '(ValueChangeEventFilter(old_value=1) or ValueChangeEventFilter(value=2))' -def test_exception_missing(): +def test_exception_missing() -> None: with pytest.raises(AttributeError) as e: EventFilter(ValueUpdateEvent, asdf=1) assert str(e.value) == 'Filter attribute "asdf" does not exist for "ValueUpdateEvent"' -def test_all_events(): +def test_all_events() -> None: assert NoEventFilter().trigger(None) is True assert NoEventFilter().trigger('') is True assert NoEventFilter().trigger(False) is True @@ -62,16 +62,16 @@ def test_all_events(): class MyValueUpdateEvent(ValueUpdateEvent): - def __init__(self, name='asdf', value='asdf'): + def __init__(self, name='asdf', value='asdf') -> None: super().__init__(name, value) class MyValueChangeEvent(ValueChangeEvent): - def __init__(self, name='asdf', value: Any = 'asdf', old_value: Any = 'asdfasdf'): + def __init__(self, name='asdf', value: Any = 'asdf', old_value: Any = 'asdfasdf') -> None: super().__init__(name, value, old_value) -def test_value_change_event_filter(): +def test_value_change_event_filter() -> None: f = ValueChangeEventFilter() assert f.trigger(MyValueUpdateEvent()) is False @@ -89,7 +89,7 @@ def test_value_change_event_filter(): assert f.trigger(MyValueChangeEvent(old_value=1)) is True -def test_filter_groups_and(): +def test_filter_groups_and() -> None: f = AndFilterGroup(ValueChangeEventFilter(old_value=1), ValueChangeEventFilter(value=2)) assert f.trigger(MyValueUpdateEvent()) is False assert f.trigger(MyValueChangeEvent()) is False @@ -99,7 +99,7 @@ def test_filter_groups_and(): assert f.trigger(MyValueChangeEvent(value=2, old_value=1)) is True -def test_filter_groups_or(): +def test_filter_groups_or() -> None: f = OrFilterGroup(ValueChangeEventFilter(old_value=1), ValueChangeEventFilter(value=2)) assert f.trigger(MyValueUpdateEvent()) is False assert f.trigger(MyValueChangeEvent()) is False diff --git a/tests/test_core/test_files/test_file_dependencies.py b/tests/test_core/test_files/test_file_dependencies.py index 6f25238d..ea0034f8 100644 --- a/tests/test_core/test_files/test_file_dependencies.py +++ b/tests/test_core/test_files/test_file_dependencies.py @@ -1,7 +1,6 @@ import logging from asyncio import sleep from pathlib import Path -from typing import List, Tuple import pytest @@ -14,23 +13,23 @@ class MockFile: - def __init__(self, name: str): + def __init__(self, name: str) -> None: self.name = name.split('/')[1] - def as_posix(self): + def as_posix(self) -> str: return f'/my_param/{self.name}' - def is_file(self): + def is_file(self) -> bool: return True - def __repr__(self): + def __repr__(self) -> str: return f'' class CfgObj: - def __init__(self): + def __init__(self) -> None: self.properties = {} - self.operation: List[Tuple[str, str]] = [] + self.operation: list[tuple[str, str]] = [] class TestFile(HABAppFile): LOGGER = logging.getLogger('test') @@ -38,17 +37,17 @@ class TestFile(HABAppFile): UNLOAD_FUNC = self.unload_file self.cls = TestFile - async def load_file(self, name: str, path: Path): + async def load_file(self, name: str, path: Path) -> None: self.operation.append(('load', name)) - async def unload_file(self, name: str, path: Path): + async def unload_file(self, name: str, path: Path) -> None: self.operation.append(('unload', name)) - async def wait_complete(self): + async def wait_complete(self) -> None: while HABApp.core.files.manager.worker.TASK is not None: await sleep(0.05) - async def process_file(self, name: str): + async def process_file(self, name: str) -> None: await process_file(name, MockFile(name)) def create_file(self, name, path) -> HABAppFile: @@ -107,7 +106,7 @@ def cfg(monkeypatch): # order.clear() -async def test_reload_dep(cfg: CfgObj, caplog): +async def test_reload_dep(cfg: CfgObj, caplog) -> None: cfg.properties['params/param1'] = FileProperties(depends_on=['params/param2'], reloads_on=['params/param2']) cfg.properties['params/param2'] = FileProperties() @@ -134,7 +133,7 @@ async def test_reload_dep(cfg: CfgObj, caplog): cfg.operation.clear() -async def test_missing_dependencies(cfg: CfgObj, test_logs: LogCollector): +async def test_missing_dependencies(cfg: CfgObj, test_logs: LogCollector) -> None: cfg.properties['params/param1'] = FileProperties(depends_on=['params/param4', 'params/param5']) cfg.properties['params/param2'] = FileProperties(depends_on=['params/param4']) cfg.properties['params/param3'] = FileProperties() diff --git a/tests/test_core/test_files/test_file_properties.py b/tests/test_core/test_files/test_file_properties.py index 8d326c09..b6f39bee 100644 --- a/tests/test_core/test_files/test_file_properties.py +++ b/tests/test_core/test_files/test_file_properties.py @@ -5,7 +5,7 @@ from tests.helpers import LogCollector -def test_prop_case(): +def test_prop_case() -> None: _in = '''# habapp: # depends on: # - my_Param.yml @@ -30,7 +30,7 @@ def test_prop_case(): assert p.reloads_on == ['my_File.py', 'other_file.py'] -def test_prop_1(): +def test_prop_1() -> None: _in = '''# HABApp: # depends on: # - my_Param.yml @@ -45,7 +45,7 @@ def test_prop_1(): assert p.reloads_on == ['my_File.py'] -def test_prop_2(): +def test_prop_2() -> None: _in = ''' # @@ -64,7 +64,7 @@ def test_prop_2(): assert p.reloads_on == [] -def test_prop_3(): +def test_prop_3() -> None: _in = ''' # @@ -79,7 +79,7 @@ def test_prop_3(): assert p.reloads_on == [] -def test_prop_missing(): +def test_prop_missing() -> None: _in = '''import bla bla bla ''' p = get_props(_in) @@ -87,7 +87,7 @@ def test_prop_missing(): assert p.reloads_on == [] -def test_deps(): +def test_deps() -> None: FILES.clear() FILES['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(depends_on=['name2'])) FILES['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties()) @@ -103,7 +103,7 @@ def test_deps(): assert f1.state is FileState.DEPENDENCIES_OK -def test_reloads(test_logs: LogCollector): +def test_reloads(test_logs: LogCollector) -> None: FILES.clear() FILES['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(reloads_on=['name2', 'asdf'])) FILES['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties()) @@ -115,7 +115,7 @@ def test_reloads(test_logs: LogCollector): test_logs.add_expected('HABApp.files', 'WARNING', "File path1 reloads on file that doesn't exist: asdf") -def test_circ(): +def test_circ() -> None: FILES.clear() FILES['name1'] = f1 = HABAppFile('name1', 'path1', FileProperties(depends_on=['name2'])) FILES['name2'] = f2 = HABAppFile('name2', 'path2', FileProperties(depends_on=['name3'])) diff --git a/tests/test_core/test_files/test_rel_name.py b/tests/test_core/test_files/test_rel_name.py index 36bde82c..b456d083 100644 --- a/tests/test_core/test_files/test_rel_name.py +++ b/tests/test_core/test_files/test_rel_name.py @@ -18,18 +18,18 @@ def cfg(): FOLDERS.clear() -def cmp(path: Path, name: str): +def cmp(path: Path, name: str) -> None: assert get_name(path) == name assert get_path(name) == path -def test_prefix_sort(cfg): +def test_prefix_sort(cfg) -> None: assert get_prefixes() == ['params/', 'configs/', 'rules/'] add_folder('params1/', Path('c:/HABApp/my_para1m/'), 50) assert get_prefixes() == ['params1/', 'params/', 'configs/', 'rules/'] -def test_from_path(cfg): +def test_from_path(cfg) -> None: cmp(Path('c:/HABApp/my_rules/rule.py'), 'rules/rule.py') cmp(Path('c:/HABApp/my_config/params.yml'), 'configs/params.yml') cmp(Path('c:/HABApp/my_param/cfg.yml'), 'params/cfg.yml') @@ -39,12 +39,12 @@ def test_from_path(cfg): cmp(Path('c:/HABApp/my_param/my_folder3/cfg.yml'), 'params/my_folder3/cfg.yml') -def test_err(cfg): +def test_err(cfg) -> None: with pytest.raises(ValueError): get_name(Path('c:/HABApp/rules/rule.py')) -def test_mixed(): +def test_mixed() -> None: FOLDERS.clear() add_folder('rules/', Path('c:/HABApp/rules'), 1) add_folder('configs/', Path('c:/HABApp/rules/my_config'), 2) diff --git a/tests/test_core/test_files/test_watcher.py b/tests/test_core/test_files/test_watcher.py index 7f972ee8..ffa54f4c 100644 --- a/tests/test_core/test_files/test_watcher.py +++ b/tests/test_core/test_files/test_watcher.py @@ -11,7 +11,7 @@ from HABApp.core.files.watcher.base_watcher import FileEndingFilter -async def test_file_events(monkeypatch, sync_worker): +async def test_file_events(monkeypatch, sync_worker) -> None: wait_time = 0.1 monkeypatch.setattr(HABApp.core.files.watcher.file_watcher, 'DEBOUNCE_TIME', wait_time) @@ -23,7 +23,7 @@ async def test_file_events(monkeypatch, sync_worker): ex = ThreadPoolExecutor(4) - def generate_events(count: int, name: str, sleep: float): + def generate_events(count: int, name: str, sleep: float) -> None: for _ in range(count): handler.dispatch(FileSystemEvent(name)) time.sleep(sleep) diff --git a/tests/test_core/test_item_registry.py b/tests/test_core/test_item_registry.py index be415102..3b3e0b5e 100644 --- a/tests/test_core/test_item_registry.py +++ b/tests/test_core/test_item_registry.py @@ -1,8 +1,11 @@ +import pytest + +from HABApp.core.errors import ItemNotFoundException from HABApp.core.internals import ItemRegistry from HABApp.core.items import Item -def test_basics(): +def test_basics() -> None: item_name = 'test' ir = ItemRegistry() @@ -17,3 +20,16 @@ def test_basics(): assert created_item == ir.pop_item(item_name) assert ir.get_items() == () + + +def test_errors() -> None: + ir = ItemRegistry() + + with pytest.raises(TypeError, match="Name must be a string not "): + Item(name=123) + + with pytest.raises(TypeError, match="Item must be of type ItemRegistryItem not "): + ir.add_item('test') + + with pytest.raises(ItemNotFoundException, match='Item asdf does not exist!'): + ir.get_item('asdf') diff --git a/tests/test_core/test_item_watch.py b/tests/test_core/test_item_watch.py index 9c8d19f2..89753a11 100644 --- a/tests/test_core/test_item_watch.py +++ b/tests/test_core/test_item_watch.py @@ -10,7 +10,7 @@ from tests.helpers.parent_rule import DummyRule -async def test_multiple_add(parent_rule: DummyRule, test_logs: LogCollector): +async def test_multiple_add(parent_rule: DummyRule, test_logs: LogCollector) -> None: i = Item('test') w1 = i.watch_change(5) @@ -23,13 +23,13 @@ async def test_multiple_add(parent_rule: DummyRule, test_logs: LogCollector): assert w1 is not w2 w2.fut.cancel() - test_logs.add_ignored('HABApp', 'WARNING', 'Watcher ItemNoChangeWatch (5s) for test has already been created') + test_logs.add_ignored('HABApp', 'WARNING', 'Watcher ItemNoChangeWatch (5.0s) for test has already been created') await asyncio.sleep(0.01) @pytest.mark.parametrize('method', ('watch_update', 'watch_change')) -async def test_watch_update(parent_rule: DummyRule, sync_worker, caplog, method): +async def test_watch_update(parent_rule: DummyRule, sync_worker, caplog, method) -> None: caplog.set_level(0) cb = MagicMock() cb.__name__ = 'MockName' diff --git a/tests/test_core/test_items/__init__.py b/tests/test_core/test_items/__init__.py index 9b1ecd45..3718254c 100644 --- a/tests/test_core/test_items/__init__.py +++ b/tests/test_core/test_items/__init__.py @@ -1 +1 @@ -from .tests_all_items import ItemTests +from .item_tests import ItemTests diff --git a/tests/test_core/test_items/item_tests.py b/tests/test_core/test_items/item_tests.py new file mode 100644 index 00000000..7a00f3f3 --- /dev/null +++ b/tests/test_core/test_items/item_tests.py @@ -0,0 +1,119 @@ +import time +from unittest.mock import MagicMock + +from whenever import Instant, patch_current_time + +from HABApp.core.events import NoEventFilter, ValueCommandEvent +from HABApp.core.internals import ItemRegistry +from HABApp.core.items import Item +from tests.helpers import TestEventBus + + +class ItemTests: + ITEM_CLASS: type[Item] | None = None + ITEM_VALUES: tuple | None = None + + def get_item(self) -> Item: + return self.ITEM_CLASS('test_name') + + def get_create_item(self) -> Item: + return self.ITEM_CLASS.get_create_item(name='test_name') + + def test_item_params(self) -> None: + assert self.ITEM_CLASS is not None + assert self.ITEM_VALUES is not None + + def test_repr(self) -> None: + item = self.get_item() + assert repr(item) + assert str(item) + + def test_factories(self, ir: ItemRegistry) -> None: + assert not ir.item_exists(self.get_item()) + + obj = self.get_create_item() + assert isinstance(obj, self.ITEM_CLASS) + + obj2 = self.ITEM_CLASS.get_item(name=obj.name) + assert obj is obj2 + + def test_var_names(self) -> None: + values = self.ITEM_VALUES + item = self.get_item() + # assert item.value is None, f'{item.value} ({type(item.value)})' + + item.set_value(values[0]) + assert item.value == values[0] + + item.post_value(values[0]) + item.get_value(default_value='asdf') + + def test_time_value_update(self) -> None: + instant = Instant.from_utc(2001, 1, 1, hour=1) + + for value in self.ITEM_VALUES: + with patch_current_time(instant.subtract(seconds=5), keep_ticking=False) as p: + item = self.get_item() + item.set_value(value) + + p.shift(seconds=5) + item.set_value(value) + + assert item._last_update.instant == instant + assert item._last_change.instant == instant.subtract(seconds=5) + + def test_time_value_change(self) -> None: + item = self.get_item() + instant = Instant.from_utc(2001, 1, 1, hour=1) + + for value in self.ITEM_VALUES: + instant.add(hours=1) + with patch_current_time(instant, keep_ticking=False): + item.set_value(value) + assert item._last_update.instant == instant + assert item._last_change.instant == instant + + def test_time_funcs(self) -> None: + item = self.get_item() + + now1 = Instant.now() + time.sleep(0.000_001) + + item.set_value(self.ITEM_VALUES[0]) + + time.sleep(0.000_001) + 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 < now2, f'\n{now1}\n{item.last_update}\n{now2}' + + item.set_value(self.ITEM_VALUES[0]) + + time.sleep(0.000_001) + now3 = Instant.now() + + assert now1 < item.last_change < now2 + assert now2 < item.last_update < now3 + + def test_post_if(self) -> None: + i = self.get_item() + assert i.post_value_if(0, is_=None) + assert i.post_value_if(1, eq=0) + assert not i.post_value_if(1, eq=0) + + def test_post_command(self, sync_worker, eb: TestEventBus) -> None: + i = self.get_item() + + mock = MagicMock() + eb.listen_events(i.name, mock, NoEventFilter()) + mock.assert_not_called() + + value = self.ITEM_VALUES[0] + i.command_value(value) + mock.assert_called() + + update = mock.call_args_list[0][0][0] + assert isinstance(update, ValueCommandEvent) + assert update.name == i.name + assert update.value == value diff --git a/tests/test_core/test_items/test_item.py b/tests/test_core/test_items/test_item.py index fc2cfe03..84b3b4a8 100644 --- a/tests/test_core/test_items/test_item.py +++ b/tests/test_core/test_items/test_item.py @@ -1,46 +1,7 @@ -import unittest -from datetime import timedelta - -from pendulum import UTC -from pendulum import now as pd_now - from HABApp.core.items import Item - -from . import ItemTests +from tests.test_core.test_items import ItemTests class TestItem(ItemTests): - CLS = Item - TEST_VALUES = [0, 'str', (1, 2, 3)] - TEST_CREATE_ITEM = {'initial_value': 0} - - -class TestCasesItem(unittest.TestCase): - - def test_repr(self): - i = Item('test') - self.assertGreater(len(str(i)), 23) - - def test_time_update(self): - i = Item('test') - i.set_value('test') - i._last_change.set(pd_now(UTC) - timedelta(seconds=5), events=False) - i._last_update.set(pd_now(UTC) - timedelta(seconds=5), events=False) - i.set_value('test') - - self.assertGreater(i._last_update.dt, pd_now(UTC) - timedelta(milliseconds=100)) - self.assertLess(i._last_change.dt, pd_now(UTC) - timedelta(milliseconds=100)) - - def test_time_change(self): - i = Item('test') - i.set_value('test') - i._last_change.set(pd_now(UTC) - timedelta(seconds=5)) - i._last_update.set(pd_now(UTC) - timedelta(seconds=5)) - i.set_value('test1') - - self.assertGreater(i._last_update.dt, pd_now(UTC) - timedelta(milliseconds=100)) - self.assertGreater(i._last_change.dt, pd_now(UTC) - timedelta(milliseconds=100)) - - -if __name__ == '__main__': - unittest.main() + ITEM_CLASS = Item + ITEM_VALUES = (0, 'str', (1, 2, 3)) diff --git a/tests/test_core/test_items/test_item_aggregation.py b/tests/test_core/test_items/test_item_aggregation.py index cfad08a9..c0adf6a3 100644 --- a/tests/test_core/test_items/test_item_aggregation.py +++ b/tests/test_core/test_items/test_item_aggregation.py @@ -3,7 +3,7 @@ from HABApp.core.items import AggregationItem, Item -async def test_aggregation_item(): +async def test_aggregation_item() -> None: agg = AggregationItem.get_create_item('MyAggregation') src = Item.get_create_item('MySource') @@ -13,7 +13,7 @@ async def test_aggregation_item(): agg.aggregation_source(src) agg.aggregation_func(lambda x: (max(x), list(x))) - async def post_val(t, v): + async def post_val(t, v) -> None: await asyncio.sleep(t) src.post_value(v) @@ -48,7 +48,7 @@ async def post_val(t, v): assert agg.value == (2, [2]) -async def test_aggregation_item_cleanup(): +async def test_aggregation_item_cleanup() -> None: agg = AggregationItem.get_create_item('MyTestAggregation') src = Item.get_create_item('MyTestSource') @@ -58,7 +58,7 @@ async def test_aggregation_item_cleanup(): agg.aggregation_source(src) agg.aggregation_func(lambda x: list(x)) - async def post_val(t, v): + async def post_val(t, v) -> None: await asyncio.sleep(t) src.post_value(v) diff --git a/tests/test_core/test_items/test_item_color.py b/tests/test_core/test_items/test_item_color.py index 0cc95335..c9e9c19d 100644 --- a/tests/test_core/test_items/test_item_color.py +++ b/tests/test_core/test_items/test_item_color.py @@ -7,11 +7,11 @@ from tests.helpers import TestEventBus -def test_repr(): +def test_repr() -> None: str(ColorItem('test')) -def test_init(): +def test_init() -> None: assert ColorItem('').hue == 0 assert ColorItem('').saturation == 0 assert ColorItem('').brightness == 0 @@ -33,7 +33,7 @@ def test_init(): ((None, None, 60), (11.11, 22.22, 60)), ] ) -def test_set_func_vals(func_name, test_vals): +def test_set_func_vals(func_name, test_vals) -> None: i = ColorItem('test', hue=11.11, saturation=22.22, brightness=33.33) assert i.hue == 11.11 assert i.saturation == 22.22 @@ -50,7 +50,7 @@ def test_set_func_vals(func_name, test_vals): assert i.value == soll -def test_set_func_tuple(): +def test_set_func_tuple() -> None: i = ColorItem('test') assert i.hue == 0 assert i.saturation == 0 @@ -64,7 +64,7 @@ def test_set_func_tuple(): assert i.value == (22, 33.3, 77) -def test_rgb_to_hsv(): +def test_rgb_to_hsv() -> None: i = ColorItem('test') i.set_rgb(193, 25, 99) @@ -74,12 +74,12 @@ def test_rgb_to_hsv(): assert tuple(int(i) for i in i.value) == (333, 87, 75) -def test_hsv_to_rgb(): +def test_hsv_to_rgb() -> None: i = ColorItem('test', 23, 44, 66) assert i.get_rgb() == (168, 123, 94) -def test_post_update(sync_worker, eb: TestEventBus): +def test_post_update(sync_worker, eb: TestEventBus) -> None: i = ColorItem('test', 23, 44, 66) mock = MagicMock() diff --git a/tests/test_core/test_items/test_item_interface.py b/tests/test_core/test_items/test_item_interface.py index 48949cc1..6d225cb0 100644 --- a/tests/test_core/test_items/test_item_interface.py +++ b/tests/test_core/test_items/test_item_interface.py @@ -5,7 +5,7 @@ from HABApp.core.items import Item -def test_pop(): +def test_pop() -> None: ir = ItemRegistry() ir.add_item(Item('test')) assert ir.item_exists('test') @@ -17,7 +17,7 @@ def test_pop(): assert not ir.item_exists('test') -def test_add(): +def test_add() -> None: ir = ItemRegistry() added = Item('test') ir.add_item(added) diff --git a/tests/test_core/test_items/test_item_times.py b/tests/test_core/test_items/test_item_times.py index 5cb8a6bb..e53595b5 100644 --- a/tests/test_core/test_items/test_item_times.py +++ b/tests/test_core/test_items/test_item_times.py @@ -2,8 +2,7 @@ from unittest.mock import MagicMock import pytest -from pendulum import UTC -from pendulum import now as pd_now +from whenever import Instant import HABApp import HABApp.core.items.tmp_data @@ -16,7 +15,7 @@ @pytest.fixture(scope='function') def u(): - a = UpdatedTime('test', pd_now(UTC)) + a = UpdatedTime('test', Instant.now()) w1 = a.add_watch(1) w2 = a.add_watch(3) @@ -31,7 +30,7 @@ def u(): @pytest.fixture(scope='function') def c(): - a = ChangedTime('test', pd_now(UTC)) + a = ChangedTime('test', Instant.now()) w1 = a.add_watch(1) w2 = a.add_watch(3) @@ -44,8 +43,8 @@ def c(): w2.cancel() -def test_sec_timedelta(parent_rule, test_logs: LogCollector): - a = UpdatedTime('test', pd_now(UTC)) +def test_sec_timedelta(parent_rule, test_logs: LogCollector) -> None: + a = UpdatedTime('test', Instant.now()) w1 = a.add_watch(1) # We return the same object because it is the same time @@ -61,13 +60,13 @@ def test_sec_timedelta(parent_rule, test_logs: LogCollector): test_logs.add_expected('HABApp', 'WARNING', 'Watcher ItemNoUpdateWatch (1s) for test has already been created') -async def test_rem(parent_rule, u: UpdatedTime): +async def test_rem(parent_rule, u: UpdatedTime) -> None: for t in u.tasks: t.cancel() -async def test_cancel_running(parent_rule, u: UpdatedTime): - u.set(pd_now(UTC)) +async def test_cancel_running(parent_rule, u: UpdatedTime) -> None: + u.set(Instant.now()) w1 = u.tasks[0] w2 = u.tasks[1] @@ -79,18 +78,18 @@ async def test_cancel_running(parent_rule, u: UpdatedTime): assert w2 in u.tasks w2.cancel() await asyncio.sleep(0.05) - u.set(pd_now(UTC)) + u.set(Instant.now()) await asyncio.sleep(0.05) assert w2 not in u.tasks -async def test_event_update(parent_rule, u: UpdatedTime, sync_worker, eb: EventBus): +async def test_event_update(parent_rule, u: UpdatedTime, sync_worker, eb: EventBus) -> None: m = MagicMock() - u.set(pd_now(UTC)) + u.set(Instant.now()) list = HABApp.core.internals.EventBusListener('test', wrap_func(m, name='MockFunc'), NoEventFilter()) eb.add_listener(list) - u.set(pd_now(UTC)) + u.set(Instant.now()) await asyncio.sleep(1) m.assert_not_called() @@ -113,13 +112,13 @@ async def test_event_update(parent_rule, u: UpdatedTime, sync_worker, eb: EventB list.cancel() -async def test_event_change(parent_rule, c: ChangedTime, sync_worker, eb: EventBus): +async def test_event_change(parent_rule, c: ChangedTime, sync_worker, eb: EventBus) -> None: m = MagicMock() - c.set(pd_now(UTC)) + c.set(Instant.now()) list = HABApp.core.internals.EventBusListener('test', wrap_func(m, name='MockFunc'), NoEventFilter()) eb.add_listener(list) - c.set(pd_now(UTC)) + c.set(Instant.now()) await asyncio.sleep(1) m.assert_not_called() @@ -143,7 +142,7 @@ async def test_event_change(parent_rule, c: ChangedTime, sync_worker, eb: EventB await asyncio.sleep(0.01) -async def test_watcher_change_restore(parent_rule, ir: ItemRegistry): +async def test_watcher_change_restore(parent_rule, ir: ItemRegistry) -> None: name = 'test_save_restore' item_a = Item(name) @@ -162,7 +161,7 @@ async def test_watcher_change_restore(parent_rule, ir: ItemRegistry): ir.pop_item(name) -async def test_watcher_update_restore(parent_rule, ir: ItemRegistry): +async def test_watcher_update_restore(parent_rule, ir: ItemRegistry) -> None: name = 'test_save_restore' item_a = Item(name) @@ -181,14 +180,14 @@ async def test_watcher_update_restore(parent_rule, ir: ItemRegistry): ir.pop_item(name) -@pytest.mark.ignore_log_warnings() +@pytest.mark.ignore_log_warnings async def test_watcher_update_cleanup(monkeypatch, parent_rule, c: ChangedTime, - sync_worker, eb: TestEventBus, ir: ItemRegistry): + sync_worker, eb: TestEventBus, ir: ItemRegistry) -> None: monkeypatch.setattr(HABApp.core.items.tmp_data.CLEANUP, 'secs', 0.7) text_warning = '' - def get_log(event): + def get_log(event) -> None: nonlocal text_warning text_warning = event diff --git a/tests/test_core/test_items/test_item_value.py b/tests/test_core/test_items/test_item_value.py index 5127e777..80bb39ce 100644 --- a/tests/test_core/test_items/test_item_value.py +++ b/tests/test_core/test_items/test_item_value.py @@ -3,7 +3,7 @@ from HABApp.core.items import BaseValueItem -def test_numeric(): +def test_numeric() -> None: a = BaseValueItem('asdf', 1) b = BaseValueItem('asdf', 5) @@ -16,7 +16,7 @@ def test_numeric(): assert a - b == -4 -def test_built_in(): +def test_built_in() -> None: a = BaseValueItem('asdf', 1.49) assert round(a, 1) == 1.5 assert round(a) == 1 @@ -25,18 +25,18 @@ def test_built_in(): assert ceil(a) == 2 -def test_unary(): +def test_unary() -> None: a = BaseValueItem('asdf', -1) assert abs(a) == 1 assert -a == 1 -def test_cast(): +def test_cast() -> None: assert float(BaseValueItem('asdf', 1)) == 1.0 assert int(BaseValueItem('asdf', 1.5)) == 1 -def test_compare(): +def test_compare() -> None: a = BaseValueItem('asdf', 1) assert a < 2 assert a <= 2 diff --git a/tests/test_core/test_items/tests_all_items.py b/tests/test_core/test_items/tests_all_items.py deleted file mode 100644 index 00ed37f3..00000000 --- a/tests/test_core/test_items/tests_all_items.py +++ /dev/null @@ -1,67 +0,0 @@ -import typing -from datetime import timedelta - -from pendulum import UTC -from pendulum import now as pd_now - -from HABApp.core.internals import ItemRegistry -from HABApp.core.items import Item - - -class ItemTests: - CLS: typing.Type[Item] = None - TEST_VALUES = [] - TEST_CREATE_ITEM = {} - - def test_test_params(self): - assert self.CLS is not None - assert self.TEST_VALUES, type(self) - - def test_factories(self, ir: ItemRegistry): - cls = self.CLS - - ITEM_NAME = 'testitem' - if ir.item_exists(ITEM_NAME): - ir.pop_item(ITEM_NAME) - - c = cls.get_create_item(name=ITEM_NAME, **self.TEST_CREATE_ITEM) - assert isinstance(c, cls) - - assert isinstance(cls.get_item(name=ITEM_NAME), cls) - - def test_var_names(self): - item = self.CLS('test') - # assert item.value is None, f'{item.value} ({type(item.value)})' - - item.set_value(self.TEST_VALUES[0]) - assert item.value == self.TEST_VALUES[0] - - item.post_value(self.TEST_VALUES[0]) - item.get_value(default_value='asdf') - - def test_time_value_update(self): - for value in self.TEST_VALUES: - i = self.CLS('test') - i.set_value(value) - i._last_change.set(pd_now(UTC) - timedelta(seconds=5), events=False) - i._last_update.set(pd_now(UTC) - timedelta(seconds=5), events=False) - i.set_value(value) - - assert i._last_update.dt > pd_now(UTC) - timedelta(milliseconds=100) - assert i._last_change.dt < pd_now(UTC) - timedelta(milliseconds=100) - - def test_time_value_change(self): - i = self.CLS('test') - for value in self.TEST_VALUES: - i._last_change.set(pd_now(UTC) - timedelta(seconds=5), events=False) - i._last_update.set(pd_now(UTC) - timedelta(seconds=5), events=False) - i.set_value(value) - - assert i._last_update.dt > pd_now(UTC) - timedelta(milliseconds=100) - assert i._last_change.dt > pd_now(UTC) - timedelta(milliseconds=100) - - def test_post_if(self): - i = self.CLS('test') - assert i.post_value_if(0, is_=None) - assert i.post_value_if(1, eq=0) - assert not i.post_value_if(1, eq=0) diff --git a/tests/test_core/test_lib/test_compare.py b/tests/test_core/test_lib/test_compare.py index bcdab868..6d04d03f 100644 --- a/tests/test_core/test_lib/test_compare.py +++ b/tests/test_core/test_lib/test_compare.py @@ -2,7 +2,7 @@ from HABApp.core.lib.funcs import compare -def test_compare_single(): +def test_compare_single() -> None: assert compare(1, eq=1) assert compare(1, equal=1) @@ -33,12 +33,12 @@ def test_compare_single(): assert not compare(None, is_not=None) -def test_compare_multi(): +def test_compare_multi() -> None: assert compare(5, le=5, ge=7) assert compare(7, le=5, ge=7) assert not compare(6, le=5, ge=7) -def test_compare_missing(): +def test_compare_missing() -> None: assert compare(5, le=5, ge=MISSING) assert not compare(7, le=5, ge=MISSING) diff --git a/tests/test_core/test_lib/test_format_traceback.py b/tests/test_core/test_lib/test_format_traceback.py index 98897bc4..c1aeecf6 100644 --- a/tests/test_core/test_lib/test_format_traceback.py +++ b/tests/test_core/test_lib/test_format_traceback.py @@ -1,13 +1,12 @@ import logging from pathlib import Path -from typing import Optional, Union import pytest from easyconfig import create_app_config from pydantic import BaseModel import HABApp -from HABApp.core.const.const import PYTHON_311 +from HABApp.core.const.const import PYTHON_311, PYTHON_312, PYTHON_313 from HABApp.core.const.json import dump_json, load_json from HABApp.core.lib import format_exception from HABApp.core.lib.exceptions.format_frame import SUPPRESSED_HABAPP_PATHS, is_lib_file, is_suppressed_habapp_file @@ -27,7 +26,7 @@ def exec_func(func) -> str: return msg -def func_obj_def_multilines(): +def func_obj_def_multilines() -> None: item = HABApp.core.items.Item a = [ 1, @@ -89,7 +88,7 @@ class DummyModel(BaseModel): CONFIG = create_app_config(DummyModel()) -def func_test_assert_none(a: Optional[str] = None, b: Optional[str] = None, c: Union[str, int] = 3): +def func_test_assert_none(a: str | None = None, b: str | None = None, c: str | int = 3) -> None: assert isinstance(a, str) or a is None, type(a) assert isinstance(b, str) or b is None, type(b) assert isinstance(c, (str, int)), type(c) @@ -104,31 +103,31 @@ def func_test_assert_none(a: Optional[str] = None, b: Optional[str] = None, c: U print(CONFIGURATION) -@pytest.mark.skipif(PYTHON_311, reason='Traceback Python 3.10') -def test_exception_expression_remove_py310(): +@pytest.mark.skipif(PYTHON_311 or PYTHON_312 or PYTHON_313, reason='Traceback Python 3.10') +def test_exception_expression_remove_py310() -> None: log.setLevel(logging.WARNING) msg = exec_func(func_test_assert_none) assert msg == r''' -File "test_core/test_lib/test_format_traceback.py", line 22 in exec_func +File "test_core/test_lib/test_format_traceback.py", line 21 in exec_func -------------------------------------------------------------------------------- - 20 | def exec_func(func) -> str: - 21 | try: ---> 22 | func() - 23 | except Exception as e: + 19 | def exec_func(func) -> str: + 20 | try: +--> 21 | func() + 22 | except Exception as e: ------------------------------------------------------------ e = ZeroDivisionError('division by zero') func = ------------------------------------------------------------ -File "test_core/test_lib/test_format_traceback.py", line 98 in func_test_assert_none +File "test_core/test_lib/test_format_traceback.py", line 97 in func_test_assert_none -------------------------------------------------------------------------------- - 92 | def func_test_assert_none(a: Optional[str] = None, b: Optional[str] = None, c: Union[str, int] = 3): + 91 | def func_test_assert_none(a: str | None = None, b: str | None = None, c: str | int = 3) -> None: (...) - 95 | assert isinstance(c, (str, int)), type(c) - 96 | CONFIGURATION = '3' - 97 | my_dict = {'key_a': 'val_a'} ---> 98 | 1 / 0 - 99 | log.error('Error message') + 94 | assert isinstance(c, (str, int)), type(c) + 95 | CONFIGURATION = '3' + 96 | my_dict = {'key_a': 'val_a'} +--> 97 | 1 / 0 + 98 | log.error('Error message') ------------------------------------------------------------ CONFIG.a = 3 a = None @@ -143,38 +142,40 @@ def test_exception_expression_remove_py310(): -------------------------------------------------------------------------------- Traceback (most recent call last): - File "test_core/test_lib/test_format_traceback.py", line 22, in exec_func + File "test_core/test_lib/test_format_traceback.py", line 21, in exec_func func() - File "test_core/test_lib/test_format_traceback.py", line 98, in func_test_assert_none + File "test_core/test_lib/test_format_traceback.py", line 97, in func_test_assert_none 1 / 0 ZeroDivisionError: division by zero''' -@pytest.mark.skipif(not PYTHON_311, reason='New traceback from python 3.11') -def test_exception_expression_remove(): +@pytest.mark.skipif( + PYTHON_313 or (not PYTHON_311 and not PYTHON_312 and not PYTHON_313), + reason='New traceback from python 3.11 and 3.12') +def test_exception_expression_remove_py_311_312() -> None: log.setLevel(logging.WARNING) msg = exec_func(func_test_assert_none) assert msg == r''' -File "test_core/test_lib/test_format_traceback.py", line 22 in exec_func +File "test_core/test_lib/test_format_traceback.py", line 21 in exec_func -------------------------------------------------------------------------------- - 20 | def exec_func(func) -> str: - 21 | try: ---> 22 | func() - 23 | except Exception as e: + 19 | def exec_func(func) -> str: + 20 | try: +--> 21 | func() + 22 | except Exception as e: ------------------------------------------------------------ e = ZeroDivisionError('division by zero') func = ------------------------------------------------------------ -File "test_core/test_lib/test_format_traceback.py", line 98 in func_test_assert_none +File "test_core/test_lib/test_format_traceback.py", line 97 in func_test_assert_none -------------------------------------------------------------------------------- - 92 | def func_test_assert_none(a: Optional[str] = None, b: Optional[str] = None, c: Union[str, int] = 3): + 91 | def func_test_assert_none(a: str | None = None, b: str | None = None, c: str | int = 3) -> None: (...) - 95 | assert isinstance(c, (str, int)), type(c) - 96 | CONFIGURATION = '3' - 97 | my_dict = {'key_a': 'val_a'} ---> 98 | 1 / 0 - 99 | log.error('Error message') + 94 | assert isinstance(c, (str, int)), type(c) + 95 | CONFIGURATION = '3' + 96 | my_dict = {'key_a': 'val_a'} +--> 97 | 1 / 0 + 98 | log.error('Error message') ------------------------------------------------------------ CONFIG.a = 3 a = None @@ -189,14 +190,132 @@ def test_exception_expression_remove(): -------------------------------------------------------------------------------- Traceback (most recent call last): - File "test_core/test_lib/test_format_traceback.py", line 22, in exec_func + File "test_core/test_lib/test_format_traceback.py", line 21, in exec_func func() - File "test_core/test_lib/test_format_traceback.py", line 98, in func_test_assert_none + File "test_core/test_lib/test_format_traceback.py", line 97, in func_test_assert_none 1 / 0 ~~^~~ ZeroDivisionError: division by zero''' +@pytest.mark.skipif(not PYTHON_313, reason='New traceback from python 3.13') +def test_exception_expression_remove() -> None: + log.setLevel(logging.WARNING) + msg = exec_func(func_test_assert_none) + assert msg == r''' +File "test_core/test_lib/test_format_traceback.py", line 21 in exec_func +-------------------------------------------------------------------------------- + 19 | def exec_func(func) -> str: + 20 | try: +--> 21 | func() + 22 | except Exception as e: + ------------------------------------------------------------ + e = ZeroDivisionError('division by zero') + func = + ------------------------------------------------------------ + +File "test_core/test_lib/test_format_traceback.py", line 97 in func_test_assert_none +-------------------------------------------------------------------------------- + 91 | def func_test_assert_none(a: str | None = None, b: str | None = None, c: str | int = 3) -> None: + (...) + 94 | assert isinstance(c, (str, int)), type(c) + 95 | CONFIGURATION = '3' + 96 | my_dict = {'key_a': 'val_a'} +--> 97 | 1 / 0 + 98 | log.error('Error message') + ------------------------------------------------------------ + CONFIG.a = 3 + a = None + b = None + c = 3 + CONFIGURATION = '3' + log = + my_dict = {'key_a': 'val_a'} + my_dict['key_a'] = 'val_a' + CONFIG.a > 2 = True + ------------------------------------------------------------ + +-------------------------------------------------------------------------------- +Traceback (most recent call last): + File "test_core/test_lib/test_format_traceback.py", line 21, in exec_func + func() + ~~~~^^ + File "test_core/test_lib/test_format_traceback.py", line 97, in func_test_assert_none + 1 / 0 + ~~^~~ +ZeroDivisionError: division by zero''' + + +def func_ir() -> None: + + from HABApp.core.items import Item + Items = HABApp.core.Items + + Items.add_item(Item('asdf')) + Items.get_item('1234') + + +@pytest.fixture +def _setup_ir(clean_objs, monkeypatch, ir, eb): + + from HABApp.core.internals.proxy import ConstProxyObj + assert isinstance(HABApp.core.Items, ConstProxyObj) + assert isinstance(HABApp.core.EventBus, ConstProxyObj) + + monkeypatch.setattr(HABApp.core, 'Items', ir) + monkeypatch.setattr(HABApp.core, 'EventBus', eb) + + yield + + +@pytest.mark.skipif(not PYTHON_313, reason='New traceback from python 3.13') +def test_skip_objs(_setup_ir) -> None: + log.setLevel(logging.WARNING) + msg = exec_func(func_ir) + assert msg == r''' +File "test_core/test_lib/test_format_traceback.py", line 21 in exec_func +-------------------------------------------------------------------------------- + 19 | def exec_func(func) -> str: + 20 | try: +--> 21 | func() + 22 | except Exception as e: + ------------------------------------------------------------ + e = ItemNotFoundException('Item 1234 does not exist!') + func = + ------------------------------------------------------------ + +File "test_core/test_lib/test_format_traceback.py", line 255 in func_ir +-------------------------------------------------------------------------------- + 249 | def func_ir() -> None: + 251 | from HABApp.core.items import Item + 252 | Items = HABApp.core.Items + 254 | Items.add_item(Item('asdf')) +--> 255 | Items.get_item('1234') + +File "internals/item_registry/item_registry.py", line 31 in get_item +-------------------------------------------------------------------------------- + 27 | def get_item(self, name: str) -> ItemRegistryItem: + 28 | try: + 29 | return self._items[name] + 30 | except KeyError: +--> 31 | raise ItemNotFoundException(name) from None + ------------------------------------------------------------ + name = '1234' + ------------------------------------------------------------ + +-------------------------------------------------------------------------------- +Traceback (most recent call last): + File "test_core/test_lib/test_format_traceback.py", line 21, in exec_func + func() + ~~~~^^ + File "test_core/test_lib/test_format_traceback.py", line 255, in func_ir + Items.get_item('1234') + ~~~~~~~~~~~~~~^^^^^^^^ + File "internals/item_registry/item_registry.py", line 31, in get_item + raise ItemNotFoundException(name) from None +HABApp.core.errors.ItemNotFoundException: Item 1234 does not exist!''' + + def test_habapp_regex(pytestconfig): files = tuple(str(f) for f in (Path(pytestconfig.rootpath) / 'src' / 'HABApp').glob('**/*')) @@ -206,10 +325,11 @@ def test_habapp_regex(pytestconfig): if regex.search(file): break else: - raise ValueError(f'Nothing matched for {regex}') + msg = f'Nothing matched for {regex}' + raise ValueError(msg) -def test_regex(pytestconfig): +def test_regex(pytestconfig) -> None: # noqa: ARG001 assert not is_suppressed_habapp_file('/lib/habapp/asdf') assert not is_suppressed_habapp_file('/lib/HABApp/asdf') 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..11fbd985 --- /dev/null +++ b/tests/test_core/test_lib/test_instant_view.py @@ -0,0 +1,71 @@ +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) -> None: + assert view > seconds(59) + 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) -> None: + assert view > TimeDelta(seconds=59) + assert view > dt_timedelta(seconds=59) + assert view > 'PT59S' + assert view > 59 + + assert view < Instant.now() + assert view < InstantView.now() + assert view == Instant.now().subtract(minutes=1) + + +def test_cmp_funcs(view: InstantView) -> None: + assert view.older_than(seconds=59) + assert view > 59 + assert view >= 60 + + assert view.newer_than(seconds=61) + assert view < 61 + assert view <= 60 + + +def test_delta_funcs(view: InstantView) -> None: + assert view.delta_now() == seconds(60) + assert view.py_timedelta() == dt_timedelta(seconds=60) + + assert view.delta_now(Instant.now()) == seconds(60) + assert view.delta_now(InstantView.now()) == seconds(60) + + with pytest.raises(ValueError) as e: + view.delta_now(Instant.now().subtract(minutes=2)) + assert str(e.value) == 'Reference instant must be newer than the instant of the InstantView' + + +def test_convert() -> None: + 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() -> None: + view = InstantView(SystemDateTime(2021, 1, 2, 10, 11, 12).instant()) + # Cut the timezone away because we don't know where the test is running + assert str(view)[:-6] == 'InstantView(2021-01-02T10:11:12+' diff --git a/tests/test_core/test_lib/test_single_task.py b/tests/test_core/test_lib/test_single_task.py index 16552c9a..31b74e20 100644 --- a/tests/test_core/test_lib/test_single_task.py +++ b/tests/test_core/test_lib/test_single_task.py @@ -4,11 +4,11 @@ from HABApp.core.lib import SingleTask -async def test_single_task_start(): +async def test_single_task_start() -> None: m = Mock() - async def cb(): + async def cb() -> None: m() st = SingleTask(cb) diff --git a/tests/test_core/test_lib/test_timeout.py b/tests/test_core/test_lib/test_timeout.py index 221646c5..0faef046 100644 --- a/tests/test_core/test_lib/test_timeout.py +++ b/tests/test_core/test_lib/test_timeout.py @@ -14,7 +14,7 @@ def time(monkeypatch) -> MockedMonotonic: return m -def assert_remaining(t: Timeout, time: float | None): +def assert_remaining(t: Timeout, time: float | None) -> None: if time is None: assert t.remaining_or_none() is None with pytest.raises(TimeoutNotRunningError): @@ -25,7 +25,7 @@ def assert_remaining(t: Timeout, time: float | None): assert abs(t.remaining_or_none() - time) < 0.000_000_1 -def test_timeout_init(time): +def test_timeout_init(time) -> None: t = Timeout(5, start=False) with pytest.raises(TimeoutNotRunningError): @@ -42,7 +42,7 @@ def test_timeout_init(time): assert_remaining(t, 5) -def test_running_expired(time): +def test_running_expired(time) -> None: t = Timeout(5) assert t.is_running() assert not t.is_running_and_expired() @@ -72,7 +72,7 @@ def test_running_expired(time): assert_remaining(t, None) -def test_start_stop_reset(time): +def test_start_stop_reset(time) -> None: t = Timeout(5, start=False) assert not t.is_running() assert_remaining(t, None) @@ -108,7 +108,7 @@ def test_start_stop_reset(time): assert_remaining(t, 7) -def test_repr(time): +def test_repr(time) -> None: assert str(Timeout(5, start=False)) == '' assert str(Timeout(10, start=False)) == '' assert str(Timeout(100, start=False)) == '' diff --git a/tests/test_core/test_lib/test_value_change.py b/tests/test_core/test_lib/test_value_change.py index a77a181a..2eaaa8d0 100644 --- a/tests/test_core/test_lib/test_value_change.py +++ b/tests/test_core/test_lib/test_value_change.py @@ -3,7 +3,7 @@ from HABApp.core.lib import ValueChange -def test_change(): +def test_change() -> None: assert not ValueChange().changed c = ValueChange[int]() @@ -22,7 +22,7 @@ def test_change(): assert c.value == 2 -def test_missing(): +def test_missing() -> None: c = ValueChange[int]() assert c.set_value(1) @@ -38,7 +38,7 @@ def test_missing(): assert c.value == 1 -def test_repr(): +def test_repr() -> None: c = ValueChange[int]() assert str(c) == ' changed: False>' diff --git a/tests/test_core/test_logger.py b/tests/test_core/test_logger.py index e9c692ac..9ce3d227 100644 --- a/tests/test_core/test_logger.py +++ b/tests/test_core/test_logger.py @@ -5,12 +5,12 @@ from tests.helpers import TestEventBus -def test_exception(): +def test_exception() -> None: e = Exception('Exception test') assert HABAppLogger(None).add_exception(e).lines == ['Exception test'] -def test_exception_multiline(): +def test_exception_multiline() -> None: e = Exception('Line1\nLine2\nLine3') assert HABAppLogger(None).add_exception(e).lines == ['Line1', 'Line2', 'Line3'] @@ -23,7 +23,7 @@ def test_exception_traceback(): assert e.lines -def test_bool(eb: TestEventBus): +def test_bool(eb: TestEventBus) -> None: eb.allow_errors = True for cls in (HABAppError, HABAppInfo, HABAppWarning): diff --git a/tests/test_core/test_types/test_color.py b/tests/test_core/test_types/test_color.py index 375b04f5..3e10d710 100644 --- a/tests/test_core/test_types/test_color.py +++ b/tests/test_core/test_types/test_color.py @@ -3,7 +3,7 @@ from HABApp.core.types.color import HSB, RGB -def test_rgb(): +def test_rgb() -> None: rgb = RGB(1, 2, 3) assert rgb.r == rgb.red == 1 assert rgb.g == rgb.green == 2 @@ -35,7 +35,7 @@ def test_rgb(): assert rgb != RGB(1, 2, 9) -def test_rgb_create(): +def test_rgb_create() -> None: for x in (-1, 256): with pytest.raises(ValueError): RGB(x, 2, 3) @@ -45,7 +45,7 @@ def test_rgb_create(): RGB(1, 2, x) -def test_rgb_replace(): +def test_rgb_replace() -> None: rgb = RGB(1, 2, 3) assert rgb.replace(r=8) == RGB(8, 2, 3) assert rgb.replace(g=8) == RGB(1, 8, 3) @@ -63,14 +63,14 @@ def test_rgb_replace(): rgb.replace(b=1, blue=1) -def test_rgb_hsb_compare(): +def test_rgb_hsb_compare() -> None: rgb = RGB(1, 2, 3) hsb = rgb.to_hsb() assert rgb == hsb assert hsb == rgb -def test_hsb(): +def test_hsb() -> None: hsb = HSB(1, 2, 3) assert hsb.h == hsb.hue == 1 assert hsb.s == hsb.saturation == 2 @@ -102,7 +102,7 @@ def test_hsb(): assert hsb != HSB(1, 2, 9) -def test_hsb_create(): +def test_hsb_create() -> None: with pytest.raises(ValueError): HSB(-1, 2, 3) with pytest.raises(ValueError): @@ -119,7 +119,7 @@ def test_hsb_create(): HSB(1, 2, 100.1) -def test_hsb_replace(): +def test_hsb_replace() -> None: hsb = HSB(1, 2, 3) assert hsb.replace(h=8) == HSB(8, 2, 3) assert hsb.replace(s=8) == HSB(1, 8, 3) diff --git a/tests/test_core/test_utilities.py b/tests/test_core/test_utilities.py index 07b9f582..50bd0293 100644 --- a/tests/test_core/test_utilities.py +++ b/tests/test_core/test_utilities.py @@ -3,10 +3,10 @@ from HABApp.core.lib import PendingFuture -async def test_pending_future(): +async def test_pending_future() -> None: a = 0 - async def b(): + async def b() -> None: nonlocal a a += 1 @@ -28,10 +28,10 @@ async def b(): assert a == 1 -async def test_pending_future_cancel(): +async def test_pending_future_cancel() -> None: exception = None - async def b(): + async def b() -> None: nonlocal exception try: await asyncio.sleep(200) diff --git a/tests/test_core/test_wrapped_func.py b/tests/test_core/test_wrapped_func.py index 4e2e4dec..273f9511 100644 --- a/tests/test_core/test_wrapped_func.py +++ b/tests/test_core/test_wrapped_func.py @@ -5,13 +5,19 @@ import pytest import HABApp -from HABApp.core.const.topics import TOPIC_ERRORS as TOPIC_ERRORS +from HABApp.core.const.topics import TOPIC_ERRORS from HABApp.core.events import NoEventFilter from HABApp.core.internals import EventBusListener, wrap_func +from HABApp.core.internals.wrapped_function.wrapped_sync import WrappedSyncFunction +from HABApp.core.internals.wrapped_function.wrapped_thread import ( + WrappedThreadFunction, + create_thread_pool, + stop_thread_pool, +) from tests.helpers import TestEventBus -def test_error(): +def test_error() -> None: with pytest.raises(TypeError) as e: wrap_func(None) assert str(e.value) == 'Callable or coroutine function expected! Got "None" (type NoneType)' @@ -25,14 +31,14 @@ def test_error(): assert str(e.value) == 'Callable or coroutine function expected! Got "2023-12-24" (type date)' -def test_sync_run(sync_worker): +def test_sync_run(sync_worker) -> None: func = Mock() f = wrap_func(func, name='mock') f.run() func.assert_called_once_with() -async def test_async_run(): +async def test_async_run() -> None: coro = AsyncMock() f = wrap_func(coro, name='coro_mock') f.run() @@ -40,14 +46,14 @@ async def test_async_run(): coro.assert_awaited_once() -def test_sync_args(sync_worker): +def test_sync_args(sync_worker) -> None: func = Mock() f = wrap_func(func, name='mock') f.run('arg1', 'arg2', kw1='kw1') func.assert_called_once_with('arg1', 'arg2', kw1='kw1') -async def test_async_args(): +async def test_async_args() -> None: coro = AsyncMock() f = wrap_func(coro, name='coro_mock') f.run('arg1', 'arg2', kw1='kw1') @@ -56,18 +62,18 @@ async def test_async_args(): coro.assert_awaited_once_with('arg1', 'arg2', kw1='kw1') -def func_div_error(): +def func_div_error() -> None: 1 / 0 -async def async_func_div_error(): +async def async_func_div_error() -> None: 1 / 0 -@pytest.mark.ignore_log_errors() +@pytest.mark.ignore_log_errors @pytest.mark.parametrize( - 'func, name', ((func_div_error, 'func_div_error'), (async_func_div_error, 'async_func_div_error'))) -async def test_async_error_wrapper(eb: TestEventBus, name, func, sync_worker): + 'func, name', [(func_div_error, 'func_div_error'), (async_func_div_error, 'async_func_div_error')]) +async def test_async_error_wrapper(eb: TestEventBus, name, func, sync_worker) -> None: eb.allow_errors = True f = wrap_func(func) @@ -84,3 +90,36 @@ async def test_async_error_wrapper(eb: TestEventBus, name, func, sync_worker): assert err.func_name == name assert isinstance(err.exception, ZeroDivisionError) assert err.traceback.startswith('File ') + + +@pytest.fixture +def thread_pool(): + create_thread_pool(2) + yield + stop_thread_pool() + + +async def test_ret_wrapped_sync_func(thread_pool) -> None: + + def func() -> int: + return 1 + + ret = await WrappedSyncFunction(func).async_run() + assert ret == 1 + + ret = await WrappedThreadFunction(func).async_run() + assert ret == 1 + + +@pytest.mark.ignore_log_errors +async def test_wrapped_sync_func(thread_pool, eb: TestEventBus) -> None: + eb.allow_errors = True + + def func() -> None: + 1/0 + + ret = await WrappedThreadFunction(func).async_run() + assert ret is None + + ret = await WrappedSyncFunction(func).async_run() + assert ret is None diff --git a/tests/test_core/test_wrapper.py b/tests/test_core/test_wrapper.py index 29a286ad..9dcd791d 100644 --- a/tests/test_core/test_wrapper.py +++ b/tests/test_core/test_wrapper.py @@ -19,7 +19,7 @@ def p_mock(monkeypatch): return m -def test_error_catch(p_mock): +def test_error_catch(p_mock) -> None: p_mock.assert_not_called() @@ -33,7 +33,7 @@ def test_error_catch(p_mock): assert p_mock.call_args[0][0] == HABApp.core.const.topics.TOPIC_WARNINGS -def test_error_level(p_mock): +def test_error_level(p_mock) -> None: with ExceptionToHABApp(log, logging.WARNING): 1 / 0 p_mock.assert_called_once() @@ -48,18 +48,18 @@ def test_error_level(p_mock): @ignore_exception -def func_a(_l): +def func_a(_l) -> None: 1 / 0 @pytest.mark.ignore_log_errors() -def test_func_wrapper(p_mock): +def test_func_wrapper(p_mock) -> None: func_a(['asdf', 'asdf']) @pytest.mark.skip(reason='Behavior still unclear') -def test_exception_format_included_files(p_mock): - async def test(): +def test_exception_format_included_files(p_mock) -> None: + async def test() -> None: async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(0.01)) as session: async with session.get('http://localhost:12345'): pass diff --git a/tests/test_debug_info.py b/tests/test_debug_info.py index 822c8c7a..0899f1d6 100644 --- a/tests/test_debug_info.py +++ b/tests/test_debug_info.py @@ -1,7 +1,7 @@ from HABApp.__debug_info__ import get_debug_info -def test_debug_info(): +def test_debug_info() -> None: info = get_debug_info() for line in info.splitlines(): diff --git a/tests/test_docs.py b/tests/test_docs.py index 3fa30520..7073ea01 100644 --- a/tests/test_docs.py +++ b/tests/test_docs.py @@ -8,7 +8,7 @@ from HABApp.config import CONFIG -def test_sample_yaml(pytestconfig): +def test_sample_yaml(pytestconfig) -> None: file = pytestconfig.rootpath / 'docs' / 'configuration.rst' all_cfgs = [] @@ -47,7 +47,7 @@ def test_sample_yaml(pytestconfig): CONFIG.load_config_dict(map) -def test_config_documentation_complete(pytestconfig): +def test_config_documentation_complete(pytestconfig) -> None: cfg_docs: Path = pytestconfig.rootpath / 'docs' / 'configuration.rst' cfg_model_dir: Path = pytestconfig.rootpath / 'src' / 'HABApp' / 'config' / 'models' assert cfg_model_dir.is_dir() diff --git a/tests/test_mqtt/test_interface.py b/tests/test_mqtt/test_interface.py index 20d2ffe4..83e26a92 100644 --- a/tests/test_mqtt/test_interface.py +++ b/tests/test_mqtt/test_interface.py @@ -3,7 +3,7 @@ from tests.helpers.inspect import assert_same_signature -def test_sync_async_signature(): +def test_sync_async_signature() -> None: assert_same_signature(async_publish, publish) assert_same_signature(async_subscribe, subscribe) assert_same_signature(async_unsubscribe, unsubscribe) diff --git a/tests/test_mqtt/test_mqtt_filters.py b/tests/test_mqtt/test_mqtt_filters.py index a4eea4e2..3ae85f4b 100644 --- a/tests/test_mqtt/test_mqtt_filters.py +++ b/tests/test_mqtt/test_mqtt_filters.py @@ -7,7 +7,7 @@ from tests.helpers.inspect import check_class_annotations, get_module_classes -def test_class_annotations(): +def test_class_annotations() -> None: """EventFilter relies on the class annotations, so we test that every event has those""" exclude = ('MqttValueChangeEventFilter', 'MqttValueUpdateEventFilter') @@ -15,7 +15,7 @@ def test_class_annotations(): check_class_annotations(cls) -def test_mqtt_filter(): +def test_mqtt_filter() -> None: f = MqttValueUpdateEventFilter(value=1) assert f.event_class is MqttValueUpdateEvent assert f.attr_name1 == 'value' diff --git a/tests/test_mqtt/test_mqtt_pair_item.py b/tests/test_mqtt/test_mqtt_pair_item.py index 1ace389c..cebdbdce 100644 --- a/tests/test_mqtt/test_mqtt_pair_item.py +++ b/tests/test_mqtt/test_mqtt_pair_item.py @@ -3,7 +3,7 @@ from HABApp.mqtt.items.mqtt_pair_item import build_write_topic -def test_name_build(): +def test_name_build() -> None: assert build_write_topic('zigbee2mqtt/my-bulb/XXX') == 'zigbee2mqtt/my-bulb/set/XXX' diff --git a/tests/test_mqtt/test_retain.py b/tests/test_mqtt/test_retain.py index 189e67e2..07f74347 100644 --- a/tests/test_mqtt/test_retain.py +++ b/tests/test_mqtt/test_retain.py @@ -3,7 +3,7 @@ class MqttDummyMsg: - def __init__(self, topic='', payload='', retain=False): + def __init__(self, topic='', payload='', retain=False) -> None: self.topic = topic self._topic = topic.encode('utf-8') self.payload = payload.encode('utf-8') @@ -11,7 +11,7 @@ def __init__(self, topic='', payload='', retain=False): self.qos = 0 -async def test_retain_create(ir: ItemRegistry): +async def test_retain_create(ir: ItemRegistry) -> None: topic = '/test/creation' assert not ir.item_exists(topic) diff --git a/tests/test_mqtt/test_values.py b/tests/test_mqtt/test_values.py index 155827bb..77b77019 100644 --- a/tests/test_mqtt/test_values.py +++ b/tests/test_mqtt/test_values.py @@ -21,7 +21,7 @@ (b'\x07\x07', '\x07\x07'), ) ) -def test_value_cast(payload, expected): +def test_value_cast(payload, expected) -> None: payload = payload.encode('utf-8') if not isinstance(payload, bytes) else payload msg = Message('test_topic', payload, None, None, None, None) assert get_msg_payload(msg) == ('test_topic', expected) diff --git a/tests/test_openhab/test_events/test_from_dict.py b/tests/test_openhab/test_events/test_from_dict.py index 51368f2c..1c575b70 100644 --- a/tests/test_openhab/test_events/test_from_dict.py +++ b/tests/test_openhab/test_events/test_from_dict.py @@ -28,7 +28,7 @@ # noinspection PyPep8Naming -def test_ItemStateEvent(): +def test_ItemStateEvent() -> None: event = get_event({'topic': 'openhab/items/Ping/state', 'payload': '{"type":"String","value":"1"}', 'type': 'ItemStateEvent'}) assert isinstance(event, ItemStateEvent) @@ -49,7 +49,7 @@ def test_ItemStateEvent(): # noinspection PyPep8Naming -def test_ItemStateUpdatedEvent(): +def test_ItemStateUpdatedEvent() -> None: event = get_event({'topic': 'openhab/items/my_item_name/stateupdated', 'payload': '{"type":"Quantity","value":"9.5 °C"}', 'type': 'ItemStateUpdatedEvent'}) assert isinstance(event, ItemStateUpdatedEvent) @@ -64,7 +64,7 @@ def test_ItemStateUpdatedEvent(): # noinspection PyPep8Naming -def test_ItemCommandEvent(): +def test_ItemCommandEvent() -> None: event = get_event({'topic': 'openhab/items/Ping/command', 'payload': '{"type":"String","value":"1"}', 'type': 'ItemCommandEvent'}) assert isinstance(event, ItemCommandEvent) @@ -73,7 +73,7 @@ def test_ItemCommandEvent(): # noinspection PyPep8Naming -def test_ItemAddedEvent1(): +def test_ItemAddedEvent1() -> None: event = get_event({ 'topic': 'openhab/items/TestString/added', 'payload': '{"type":"String","name":"TestString","label":"MyLabel","category":"","tags":[],"groupNames":[]}', @@ -86,7 +86,7 @@ def test_ItemAddedEvent1(): # noinspection PyPep8Naming -def test_ItemAddedEvent2(): +def test_ItemAddedEvent2() -> None: event = get_event({ 'topic': 'openhab/items/TestColor_OFF/added', 'payload': '{"type":"Color","name":"TestColor_OFF","tags":[],"groupNames":["TestGroup"]}', @@ -115,7 +115,7 @@ def test_ItemAddedEvent2(): # noinspection PyPep8Naming -def test_ItemUpdatedEvent(): +def test_ItemUpdatedEvent() -> None: event = get_event({ 'topic': 'openhab/items/NameUpdated/updated', 'payload': '[{"type":"Switch","name":"Test","tags":[],"groupNames":[]},' @@ -144,7 +144,7 @@ def test_ItemUpdatedEvent(): # noinspection PyPep8Naming -def test_ItemStateChangedEvent1(): +def test_ItemStateChangedEvent1() -> None: event = get_event({'topic': 'openhab/items/Ping/statechanged', 'payload': '{"type":"String","value":"1","oldType":"UnDef","oldValue":"NULL"}', 'type': 'ItemStateChangedEvent'}) @@ -155,7 +155,7 @@ def test_ItemStateChangedEvent1(): # noinspection PyPep8Naming -def test_ItemStatePredictedEvent(): +def test_ItemStatePredictedEvent() -> None: event = get_event({'topic': 'openhab/items/Buero_Lampe_Vorne_W/statepredicted', 'payload': '{"predictedType":"Percent","predictedValue":"10","isConfirmation":false}', 'type': 'ItemStatePredictedEvent'}) @@ -165,7 +165,7 @@ def test_ItemStatePredictedEvent(): # noinspection PyPep8Naming -def test_ItemStateChangedEvent2(): +def test_ItemStateChangedEvent2() -> None: UTC_OFFSET = datetime.datetime.now().astimezone(None).strftime('%z') _in = { @@ -182,7 +182,7 @@ def test_ItemStateChangedEvent2(): # noinspection PyPep8Naming -def test_GroupStateUpdatedEvent(): +def test_GroupStateUpdatedEvent() -> None: d = { 'topic': 'openhab/items/GroupThatChanged/ItemThatCausedChange/stateupdated', 'payload': '{"type":"OnOff","value":"ON"}', @@ -196,7 +196,7 @@ def test_GroupStateUpdatedEvent(): # noinspection PyPep8Naming -def test_GroupItemStateChangedEvent(): +def test_GroupItemStateChangedEvent() -> None: d = { 'topic': 'openhab/items/TestGroupAVG/TestNumber1/statechanged', 'payload': '{"type":"Decimal","value":"16","oldType":"Decimal","oldValue":"15"}', @@ -211,7 +211,7 @@ def test_GroupItemStateChangedEvent(): # noinspection PyPep8Naming -def test_channel_ChannelTriggeredEvent(): +def test_channel_ChannelTriggeredEvent() -> None: d = { 'topic': 'openhab/channels/mihome:sensor_switch:00000000000000:button/triggered', 'payload': '{\"event\":\"SHORT_PRESSED\",\"channel\":\"mihome:sensor_switch:11111111111111:button\"}', @@ -226,7 +226,7 @@ def test_channel_ChannelTriggeredEvent(): # noinspection PyPep8Naming -def test_channel_ChannelDescriptionChangedEvent(): +def test_channel_ChannelDescriptionChangedEvent() -> None: data = { 'topic': 'openhab/channels/lgwebos:WebOSTV:**********************:channel/descriptionchanged', 'payload': '{"field":"STATE_OPTIONS","channelUID":"lgwebos:WebOSTV:**********************:channel",' @@ -241,7 +241,7 @@ def test_channel_ChannelDescriptionChangedEvent(): # noinspection PyPep8Naming -def test_thing_ThingStatusInfoEvent(): +def test_thing_ThingStatusInfoEvent() -> None: data = { 'topic': 'openhab/things/samsungtv:tv:mysamsungtv/status', 'payload': '{"status":"ONLINE","statusDetail":"BRIDGE_OFFLINE"}', @@ -288,7 +288,7 @@ def test_thing_ThingStatusInfoEvent(): # noinspection PyPep8Naming -def test_thing_ThingStatusInfoChangedEvent(): +def test_thing_ThingStatusInfoChangedEvent() -> None: data = { 'topic': 'openhab/things/samsungtv:tv:mysamsungtv/statuschanged', 'payload': '[{"status":"OFFLINE","statusDetail":"NONE"},{"status":"ONLINE","statusDetail":"NONE"}]', @@ -328,7 +328,7 @@ def test_thing_ThingStatusInfoChangedEvent(): # noinspection PyPep8Naming -def test_thing_FirmwareStatusEvent(): +def test_thing_FirmwareStatusEvent() -> None: data = { 'topic': 'openhab/things/zigbee:device:12345678:9abcdefghijklmno/firmware/status', 'payload': @@ -341,7 +341,7 @@ def test_thing_FirmwareStatusEvent(): # noinspection PyPep8Naming -def test_thing_ThingAddedEvent(): +def test_thing_ThingAddedEvent() -> None: data = { 'topic': 'openhab/things/astro:sun:0a94363608/added', 'payload': '{"channels":[{"uid":"astro:sun:0a94363608:rise#start","id":"rise#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:rise#end","id":"rise#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:rise#duration","id":"rise#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:rise#event","id":"rise#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#start","id":"set#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#end","id":"set#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#duration","id":"set#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:set#event","id":"set#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#start","id":"noon#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#end","id":"noon#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#duration","id":"noon#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:noon#event","id":"noon#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#start","id":"night#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#end","id":"night#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#duration","id":"night#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:night#event","id":"night#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#start","id":"morningNight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#end","id":"morningNight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#duration","id":"morningNight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:morningNight#event","id":"morningNight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#start","id":"astroDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#end","id":"astroDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#duration","id":"astroDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:astroDawn#event","id":"astroDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#start","id":"nauticDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#end","id":"nauticDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#duration","id":"nauticDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:nauticDawn#event","id":"nauticDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#start","id":"civilDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#end","id":"civilDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#duration","id":"civilDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:civilDawn#event","id":"civilDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#start","id":"astroDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#end","id":"astroDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#duration","id":"astroDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:astroDusk#event","id":"astroDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#start","id":"nauticDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#end","id":"nauticDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#duration","id":"nauticDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:nauticDusk#event","id":"nauticDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#start","id":"civilDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#end","id":"civilDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#duration","id":"civilDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:civilDusk#event","id":"civilDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#start","id":"eveningNight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#end","id":"eveningNight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#duration","id":"eveningNight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eveningNight#event","id":"eveningNight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#start","id":"daylight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#end","id":"daylight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#duration","id":"daylight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:daylight#event","id":"daylight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:position#azimuth","id":"position#azimuth","channelTypeUID":"astro:azimuth","itemType":"Number:Angle","kind":"STATE","label":"Azimut","description":"Das Azimut des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:position#elevation","id":"position#elevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:position#shadeLength","id":"position#shadeLength","channelTypeUID":"astro:shadeLength","itemType":"Number","kind":"STATE","label":"Schattenlängenverhältnis","description":"Projiziertes Schattenlängenverhältnis (Abgeleitet vom Höhenwinkel)","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#direct","id":"radiation#direct","channelTypeUID":"astro:directRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Direkte Strahlung","description":"Höhe der Strahlung nach Eindringen in die atmosphärische Schicht","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#diffuse","id":"radiation#diffuse","channelTypeUID":"astro:diffuseRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Diffuse Strahlung","description":"Höhe der Strahlung, nach Beugung durch Wolken und Atmosphäre","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#total","id":"radiation#total","channelTypeUID":"astro:totalRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Gesamtstrahlung","description":"Gesamtmenge der Strahlung auf dem Boden","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:zodiac#start","id":"zodiac#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:zodiac#end","id":"zodiac#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:zodiac#sign","id":"zodiac#sign","channelTypeUID":"astro:sign","itemType":"String","kind":"STATE","label":"Sternzeichen","description":"Das Sternzeichen","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#name","id":"season#name","channelTypeUID":"astro:seasonName","itemType":"String","kind":"STATE","label":"Jahreszeit","description":"Der Name der aktuellen Jahreszeit","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#spring","id":"season#spring","channelTypeUID":"astro:spring","itemType":"DateTime","kind":"STATE","label":"Frühling","description":"Frühlingsanfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#summer","id":"season#summer","channelTypeUID":"astro:summer","itemType":"DateTime","kind":"STATE","label":"Sommer","description":"Sommeranfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#autumn","id":"season#autumn","channelTypeUID":"astro:autumn","itemType":"DateTime","kind":"STATE","label":"Herbst","description":"Herbstanfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#winter","id":"season#winter","channelTypeUID":"astro:winter","itemType":"DateTime","kind":"STATE","label":"Winter","description":"Winteranfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#nextName","id":"season#nextName","channelTypeUID":"astro:seasonName","itemType":"String","kind":"STATE","label":"Nächste Jahreszeit","description":"Der Name der nächsten Jahreszeit","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#timeLeft","id":"season#timeLeft","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Verbleibende Zeit","description":"Die verbleibende Zeit bis zum nächsten Jahreszeitwechsel","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#total","id":"eclipse#total","channelTypeUID":"astro:total","itemType":"DateTime","kind":"STATE","label":"Totale Sonnenfinsternis","description":"Zeitpunkt der nächsten totalen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#totalElevation","id":"eclipse#totalElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#partial","id":"eclipse#partial","channelTypeUID":"astro:partial","itemType":"DateTime","kind":"STATE","label":"Partielle Sonnenfinsternis","description":"Zeitpunkt der nächsten partiellen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#partialElevation","id":"eclipse#partialElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#ring","id":"eclipse#ring","channelTypeUID":"astro:ring","itemType":"DateTime","kind":"STATE","label":"Ringförmige Sonnenfinsternis","description":"Zeitpunkt der nächsten ringförmigen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#ringElevation","id":"eclipse#ringElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#event","id":"eclipse#event","channelTypeUID":"astro:sunEclipseEvent","kind":"TRIGGER","label":"Sonnenfinsternisereignis","description":"Sonnenfinsternisereignis","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:phase#name","id":"phase#name","channelTypeUID":"astro:sunPhaseName","itemType":"String","kind":"STATE","label":"Sonnenphase","description":"Der Name der aktuellen Sonnenphase","defaultTags":[],"properties":{},"configuration":{}}],"label":"Astronomische Sonnendaten","configuration":{"useMeteorologicalSeason":false,"interval":300,"geolocation":"50.65294336725709,8.349609375000002"},"properties":{},"UID":"astro:sun:0a94363608","thingTypeUID":"astro:sun"}', # noqa: E501 @@ -359,7 +359,7 @@ def test_thing_ThingAddedEvent(): # noinspection PyPep8Naming -def test_thing_ThingRemovedEvent(): +def test_thing_ThingRemovedEvent() -> None: data = { 'topic': 'openhab/things/astro:sun:0a94363608/removed', 'payload': '{"channels":[{"uid":"astro:sun:0a94363608:rise#start","id":"rise#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:rise#end","id":"rise#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:rise#duration","id":"rise#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:rise#event","id":"rise#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#start","id":"set#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#end","id":"set#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:set#duration","id":"set#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:set#event","id":"set#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#start","id":"noon#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#end","id":"noon#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:noon#duration","id":"noon#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:noon#event","id":"noon#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#start","id":"night#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#end","id":"night#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:night#duration","id":"night#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:night#event","id":"night#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#start","id":"morningNight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#end","id":"morningNight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:morningNight#duration","id":"morningNight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:morningNight#event","id":"morningNight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#start","id":"astroDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#end","id":"astroDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDawn#duration","id":"astroDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:astroDawn#event","id":"astroDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#start","id":"nauticDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#end","id":"nauticDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDawn#duration","id":"nauticDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:nauticDawn#event","id":"nauticDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#start","id":"civilDawn#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#end","id":"civilDawn#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDawn#duration","id":"civilDawn#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:civilDawn#event","id":"civilDawn#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#start","id":"astroDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#end","id":"astroDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:astroDusk#duration","id":"astroDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:astroDusk#event","id":"astroDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#start","id":"nauticDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#end","id":"nauticDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:nauticDusk#duration","id":"nauticDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:nauticDusk#event","id":"nauticDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#start","id":"civilDusk#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#end","id":"civilDusk#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:civilDusk#duration","id":"civilDusk#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:civilDusk#event","id":"civilDusk#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#start","id":"eveningNight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#end","id":"eveningNight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:eveningNight#duration","id":"eveningNight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eveningNight#event","id":"eveningNight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#start","id":"daylight#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#end","id":"daylight#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:daylight#duration","id":"daylight#duration","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Dauer","description":"Die Dauer des Ereignisses","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:daylight#event","id":"daylight#event","channelTypeUID":"astro:rangeEvent","kind":"TRIGGER","label":"Zeitraum","description":"Zeitraum für ein Ereignis.","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:position#azimuth","id":"position#azimuth","channelTypeUID":"astro:azimuth","itemType":"Number:Angle","kind":"STATE","label":"Azimut","description":"Das Azimut des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:position#elevation","id":"position#elevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:position#shadeLength","id":"position#shadeLength","channelTypeUID":"astro:shadeLength","itemType":"Number","kind":"STATE","label":"Schattenlängenverhältnis","description":"Projiziertes Schattenlängenverhältnis (Abgeleitet vom Höhenwinkel)","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#direct","id":"radiation#direct","channelTypeUID":"astro:directRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Direkte Strahlung","description":"Höhe der Strahlung nach Eindringen in die atmosphärische Schicht","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#diffuse","id":"radiation#diffuse","channelTypeUID":"astro:diffuseRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Diffuse Strahlung","description":"Höhe der Strahlung, nach Beugung durch Wolken und Atmosphäre","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:radiation#total","id":"radiation#total","channelTypeUID":"astro:totalRadiation","itemType":"Number:Intensity","kind":"STATE","label":"Gesamtstrahlung","description":"Gesamtmenge der Strahlung auf dem Boden","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:zodiac#start","id":"zodiac#start","channelTypeUID":"astro:start","itemType":"DateTime","kind":"STATE","label":"Startzeit","description":"Die Startzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:zodiac#end","id":"zodiac#end","channelTypeUID":"astro:end","itemType":"DateTime","kind":"STATE","label":"Endzeit","description":"Die Endzeit des Ereignisses","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:zodiac#sign","id":"zodiac#sign","channelTypeUID":"astro:sign","itemType":"String","kind":"STATE","label":"Sternzeichen","description":"Das Sternzeichen","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#name","id":"season#name","channelTypeUID":"astro:seasonName","itemType":"String","kind":"STATE","label":"Jahreszeit","description":"Der Name der aktuellen Jahreszeit","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#spring","id":"season#spring","channelTypeUID":"astro:spring","itemType":"DateTime","kind":"STATE","label":"Frühling","description":"Frühlingsanfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#summer","id":"season#summer","channelTypeUID":"astro:summer","itemType":"DateTime","kind":"STATE","label":"Sommer","description":"Sommeranfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#autumn","id":"season#autumn","channelTypeUID":"astro:autumn","itemType":"DateTime","kind":"STATE","label":"Herbst","description":"Herbstanfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#winter","id":"season#winter","channelTypeUID":"astro:winter","itemType":"DateTime","kind":"STATE","label":"Winter","description":"Winteranfang","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#nextName","id":"season#nextName","channelTypeUID":"astro:seasonName","itemType":"String","kind":"STATE","label":"Nächste Jahreszeit","description":"Der Name der nächsten Jahreszeit","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:season#timeLeft","id":"season#timeLeft","channelTypeUID":"astro:duration","itemType":"Number:Time","kind":"STATE","label":"Verbleibende Zeit","description":"Die verbleibende Zeit bis zum nächsten Jahreszeitwechsel","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#total","id":"eclipse#total","channelTypeUID":"astro:total","itemType":"DateTime","kind":"STATE","label":"Totale Sonnenfinsternis","description":"Zeitpunkt der nächsten totalen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#totalElevation","id":"eclipse#totalElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#partial","id":"eclipse#partial","channelTypeUID":"astro:partial","itemType":"DateTime","kind":"STATE","label":"Partielle Sonnenfinsternis","description":"Zeitpunkt der nächsten partiellen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#partialElevation","id":"eclipse#partialElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#ring","id":"eclipse#ring","channelTypeUID":"astro:ring","itemType":"DateTime","kind":"STATE","label":"Ringförmige Sonnenfinsternis","description":"Zeitpunkt der nächsten ringförmigen Sonnenfinsternis","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#ringElevation","id":"eclipse#ringElevation","channelTypeUID":"astro:elevation","itemType":"Number:Angle","kind":"STATE","label":"Höhenwinkel","description":"Der Höhenwinkel des Himmelskörpers","defaultTags":[],"properties":{},"configuration":{}},{"uid":"astro:sun:0a94363608:eclipse#event","id":"eclipse#event","channelTypeUID":"astro:sunEclipseEvent","kind":"TRIGGER","label":"Sonnenfinsternisereignis","description":"Sonnenfinsternisereignis","defaultTags":[],"properties":{},"configuration":{"offset":0}},{"uid":"astro:sun:0a94363608:phase#name","id":"phase#name","channelTypeUID":"astro:sunPhaseName","itemType":"String","kind":"STATE","label":"Sonnenphase","description":"Der Name der aktuellen Sonnenphase","defaultTags":[],"properties":{},"configuration":{}}],"label":"Astronomische Sonnendaten","configuration":{"useMeteorologicalSeason":false,"interval":300,"geolocation":"50.65294336725709,8.349609375000002"},"properties":{},"UID":"astro:sun:0a94363608","thingTypeUID":"astro:sun"}', # noqa: E501 @@ -377,7 +377,7 @@ def test_thing_ThingRemovedEvent(): # noinspection PyPep8Naming -def test_thing_ThingUpdatedEvent(): +def test_thing_ThingUpdatedEvent() -> None: data = { 'topic': 'openhab/things/astro:sun:local/updated', 'payload': '[{\"channels\":[{\"uid\":\"astro:sun:local:rise#start\",\"id\":\"rise#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:rise#end\",\"id\":\"rise#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:rise#duration\",\"id\":\"rise#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:rise#event\",\"id\":\"rise#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#start\",\"id\":\"set#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#end\",\"id\":\"set#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#duration\",\"id\":\"set#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:set#event\",\"id\":\"set#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#start\",\"id\":\"noon#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#end\",\"id\":\"noon#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#duration\",\"id\":\"noon#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:noon#event\",\"id\":\"noon#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#start\",\"id\":\"night#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#end\",\"id\":\"night#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#duration\",\"id\":\"night#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:night#event\",\"id\":\"night#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#start\",\"id\":\"morningNight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#end\",\"id\":\"morningNight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#duration\",\"id\":\"morningNight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:morningNight#event\",\"id\":\"morningNight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#start\",\"id\":\"astroDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#end\",\"id\":\"astroDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#duration\",\"id\":\"astroDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:astroDawn#event\",\"id\":\"astroDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#start\",\"id\":\"nauticDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#end\",\"id\":\"nauticDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#duration\",\"id\":\"nauticDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:nauticDawn#event\",\"id\":\"nauticDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#start\",\"id\":\"civilDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#end\",\"id\":\"civilDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#duration\",\"id\":\"civilDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:civilDawn#event\",\"id\":\"civilDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#start\",\"id\":\"astroDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#end\",\"id\":\"astroDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#duration\",\"id\":\"astroDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:astroDusk#event\",\"id\":\"astroDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#start\",\"id\":\"nauticDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#end\",\"id\":\"nauticDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#duration\",\"id\":\"nauticDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:nauticDusk#event\",\"id\":\"nauticDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#start\",\"id\":\"civilDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#end\",\"id\":\"civilDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#duration\",\"id\":\"civilDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:civilDusk#event\",\"id\":\"civilDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#start\",\"id\":\"eveningNight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#end\",\"id\":\"eveningNight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#duration\",\"id\":\"eveningNight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eveningNight#event\",\"id\":\"eveningNight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#start\",\"id\":\"daylight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#end\",\"id\":\"daylight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#duration\",\"id\":\"daylight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:daylight#event\",\"id\":\"daylight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:position#azimuth\",\"id\":\"position#azimuth\",\"channelTypeUID\":\"astro:azimuth\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Azimut\",\"description\":\"Das Azimut des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:position#elevation\",\"id\":\"position#elevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:position#shadeLength\",\"id\":\"position#shadeLength\",\"channelTypeUID\":\"astro:shadeLength\",\"itemType\":\"Number\",\"kind\":\"STATE\",\"label\":\"Schattenlängenverhältnis\",\"description\":\"Projiziertes Schattenlängenverhältnis (Abgeleitet vom Höhenwinkel)\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#direct\",\"id\":\"radiation#direct\",\"channelTypeUID\":\"astro:directRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Direkte Strahlung\",\"description\":\"Höhe der Strahlung nach Eindringen in die atmosphärische Schicht\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#diffuse\",\"id\":\"radiation#diffuse\",\"channelTypeUID\":\"astro:diffuseRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Diffuse Strahlung\",\"description\":\"Höhe der Strahlung, nach Beugung durch Wolken und Atmosphäre\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#total\",\"id\":\"radiation#total\",\"channelTypeUID\":\"astro:totalRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Gesamtstrahlung\",\"description\":\"Gesamtmenge der Strahlung auf dem Boden\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:zodiac#start\",\"id\":\"zodiac#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:zodiac#end\",\"id\":\"zodiac#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:zodiac#sign\",\"id\":\"zodiac#sign\",\"channelTypeUID\":\"astro:sign\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Sternzeichen\",\"description\":\"Das Sternzeichen\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#name\",\"id\":\"season#name\",\"channelTypeUID\":\"astro:seasonName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Jahreszeit\",\"description\":\"Der Name der aktuellen Jahreszeit\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#spring\",\"id\":\"season#spring\",\"channelTypeUID\":\"astro:spring\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Frühling\",\"description\":\"Frühlingsanfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#summer\",\"id\":\"season#summer\",\"channelTypeUID\":\"astro:summer\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Sommer\",\"description\":\"Sommeranfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#autumn\",\"id\":\"season#autumn\",\"channelTypeUID\":\"astro:autumn\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Herbst\",\"description\":\"Herbstanfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#winter\",\"id\":\"season#winter\",\"channelTypeUID\":\"astro:winter\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Winter\",\"description\":\"Winteranfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#nextName\",\"id\":\"season#nextName\",\"channelTypeUID\":\"astro:seasonName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Nächste Jahreszeit\",\"description\":\"Der Name der nächsten Jahreszeit\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#timeLeft\",\"id\":\"season#timeLeft\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Verbleibende Zeit\",\"description\":\"Die verbleibende Zeit bis zum nächsten Jahreszeitwechsel\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#total\",\"id\":\"eclipse#total\",\"channelTypeUID\":\"astro:total\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Totale Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten totalen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#totalElevation\",\"id\":\"eclipse#totalElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#partial\",\"id\":\"eclipse#partial\",\"channelTypeUID\":\"astro:partial\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Partielle Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten partiellen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#partialElevation\",\"id\":\"eclipse#partialElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#ring\",\"id\":\"eclipse#ring\",\"channelTypeUID\":\"astro:ring\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Ringförmige Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten ringförmigen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#ringElevation\",\"id\":\"eclipse#ringElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#event\",\"id\":\"eclipse#event\",\"channelTypeUID\":\"astro:sunEclipseEvent\",\"kind\":\"TRIGGER\",\"label\":\"Sonnenfinsternisereignis\",\"description\":\"Sonnenfinsternisereignis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:phase#name\",\"id\":\"phase#name\",\"channelTypeUID\":\"astro:sunPhaseName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Sonnenphase\",\"description\":\"Der Name der aktuellen Sonnenphase\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}}],\"label\":\"Lokale Sonnendaten\",\"configuration\":{\"useMeteorologicalSeason\":false,\"interval\":300,\"geolocation\":\"52.175458141853085,12.015266418457033\"},\"properties\":{},\"UID\":\"astro:sun:local\",\"thingTypeUID\":\"astro:sun\"},{\"channels\":[{\"uid\":\"astro:sun:local:rise#start\",\"id\":\"rise#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:rise#end\",\"id\":\"rise#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:rise#duration\",\"id\":\"rise#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:rise#event\",\"id\":\"rise#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#start\",\"id\":\"set#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#end\",\"id\":\"set#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:set#duration\",\"id\":\"set#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:set#event\",\"id\":\"set#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#start\",\"id\":\"noon#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#end\",\"id\":\"noon#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:noon#duration\",\"id\":\"noon#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:noon#event\",\"id\":\"noon#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#start\",\"id\":\"night#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#end\",\"id\":\"night#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:night#duration\",\"id\":\"night#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:night#event\",\"id\":\"night#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#start\",\"id\":\"morningNight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#end\",\"id\":\"morningNight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:morningNight#duration\",\"id\":\"morningNight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:morningNight#event\",\"id\":\"morningNight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#start\",\"id\":\"astroDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#end\",\"id\":\"astroDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDawn#duration\",\"id\":\"astroDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:astroDawn#event\",\"id\":\"astroDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#start\",\"id\":\"nauticDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#end\",\"id\":\"nauticDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDawn#duration\",\"id\":\"nauticDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:nauticDawn#event\",\"id\":\"nauticDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#start\",\"id\":\"civilDawn#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#end\",\"id\":\"civilDawn#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDawn#duration\",\"id\":\"civilDawn#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:civilDawn#event\",\"id\":\"civilDawn#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#start\",\"id\":\"astroDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#end\",\"id\":\"astroDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:astroDusk#duration\",\"id\":\"astroDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:astroDusk#event\",\"id\":\"astroDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#start\",\"id\":\"nauticDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#end\",\"id\":\"nauticDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:nauticDusk#duration\",\"id\":\"nauticDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:nauticDusk#event\",\"id\":\"nauticDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#start\",\"id\":\"civilDusk#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#end\",\"id\":\"civilDusk#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:civilDusk#duration\",\"id\":\"civilDusk#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:civilDusk#event\",\"id\":\"civilDusk#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#start\",\"id\":\"eveningNight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#end\",\"id\":\"eveningNight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:eveningNight#duration\",\"id\":\"eveningNight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eveningNight#event\",\"id\":\"eveningNight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#start\",\"id\":\"daylight#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#end\",\"id\":\"daylight#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:daylight#duration\",\"id\":\"daylight#duration\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Dauer\",\"description\":\"Die Dauer des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:daylight#event\",\"id\":\"daylight#event\",\"channelTypeUID\":\"astro:rangeEvent\",\"kind\":\"TRIGGER\",\"label\":\"Zeitraum\",\"description\":\"Zeitraum für ein Ereignis.\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:position#azimuth\",\"id\":\"position#azimuth\",\"channelTypeUID\":\"astro:azimuth\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Azimut\",\"description\":\"Das Azimut des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:position#elevation\",\"id\":\"position#elevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:position#shadeLength\",\"id\":\"position#shadeLength\",\"channelTypeUID\":\"astro:shadeLength\",\"itemType\":\"Number\",\"kind\":\"STATE\",\"label\":\"Schattenlängenverhältnis\",\"description\":\"Projiziertes Schattenlängenverhältnis (Abgeleitet vom Höhenwinkel)\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#direct\",\"id\":\"radiation#direct\",\"channelTypeUID\":\"astro:directRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Direkte Strahlung\",\"description\":\"Höhe der Strahlung nach Eindringen in die atmosphärische Schicht\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#diffuse\",\"id\":\"radiation#diffuse\",\"channelTypeUID\":\"astro:diffuseRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Diffuse Strahlung\",\"description\":\"Höhe der Strahlung, nach Beugung durch Wolken und Atmosphäre\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:radiation#total\",\"id\":\"radiation#total\",\"channelTypeUID\":\"astro:totalRadiation\",\"itemType\":\"Number:Intensity\",\"kind\":\"STATE\",\"label\":\"Gesamtstrahlung\",\"description\":\"Gesamtmenge der Strahlung auf dem Boden\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:zodiac#start\",\"id\":\"zodiac#start\",\"channelTypeUID\":\"astro:start\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Startzeit\",\"description\":\"Die Startzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:zodiac#end\",\"id\":\"zodiac#end\",\"channelTypeUID\":\"astro:end\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Endzeit\",\"description\":\"Die Endzeit des Ereignisses\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:zodiac#sign\",\"id\":\"zodiac#sign\",\"channelTypeUID\":\"astro:sign\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Sternzeichen\",\"description\":\"Das Sternzeichen\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#name\",\"id\":\"season#name\",\"channelTypeUID\":\"astro:seasonName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Jahreszeit\",\"description\":\"Der Name der aktuellen Jahreszeit\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#spring\",\"id\":\"season#spring\",\"channelTypeUID\":\"astro:spring\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Frühling\",\"description\":\"Frühlingsanfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#summer\",\"id\":\"season#summer\",\"channelTypeUID\":\"astro:summer\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Sommer\",\"description\":\"Sommeranfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#autumn\",\"id\":\"season#autumn\",\"channelTypeUID\":\"astro:autumn\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Herbst\",\"description\":\"Herbstanfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#winter\",\"id\":\"season#winter\",\"channelTypeUID\":\"astro:winter\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Winter\",\"description\":\"Winteranfang\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#nextName\",\"id\":\"season#nextName\",\"channelTypeUID\":\"astro:seasonName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Nächste Jahreszeit\",\"description\":\"Der Name der nächsten Jahreszeit\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:season#timeLeft\",\"id\":\"season#timeLeft\",\"channelTypeUID\":\"astro:duration\",\"itemType\":\"Number:Time\",\"kind\":\"STATE\",\"label\":\"Verbleibende Zeit\",\"description\":\"Die verbleibende Zeit bis zum nächsten Jahreszeitwechsel\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#total\",\"id\":\"eclipse#total\",\"channelTypeUID\":\"astro:total\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Totale Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten totalen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#totalElevation\",\"id\":\"eclipse#totalElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#partial\",\"id\":\"eclipse#partial\",\"channelTypeUID\":\"astro:partial\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Partielle Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten partiellen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#partialElevation\",\"id\":\"eclipse#partialElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#ring\",\"id\":\"eclipse#ring\",\"channelTypeUID\":\"astro:ring\",\"itemType\":\"DateTime\",\"kind\":\"STATE\",\"label\":\"Ringförmige Sonnenfinsternis\",\"description\":\"Zeitpunkt der nächsten ringförmigen Sonnenfinsternis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#ringElevation\",\"id\":\"eclipse#ringElevation\",\"channelTypeUID\":\"astro:elevation\",\"itemType\":\"Number:Angle\",\"kind\":\"STATE\",\"label\":\"Höhenwinkel\",\"description\":\"Der Höhenwinkel des Himmelskörpers\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}},{\"uid\":\"astro:sun:local:eclipse#event\",\"id\":\"eclipse#event\",\"channelTypeUID\":\"astro:sunEclipseEvent\",\"kind\":\"TRIGGER\",\"label\":\"Sonnenfinsternisereignis\",\"description\":\"Sonnenfinsternisereignis\",\"defaultTags\":[],\"properties\":{},\"configuration\":{\"offset\":0}},{\"uid\":\"astro:sun:local:phase#name\",\"id\":\"phase#name\",\"channelTypeUID\":\"astro:sunPhaseName\",\"itemType\":\"String\",\"kind\":\"STATE\",\"label\":\"Sonnenphase\",\"description\":\"Der Name der aktuellen Sonnenphase\",\"defaultTags\":[],\"properties\":{},\"configuration\":{}}],\"label\":\"Lokale Sonnendaten\",\"configuration\":{\"useMeteorologicalSeason\":false,\"interval\":300,\"geolocation\":\"52.17651083497927,12.022132873535158\"},\"properties\":{},\"UID\":\"astro:sun:local\",\"thingTypeUID\":\"astro:sun\"}]', # noqa: E501 @@ -389,7 +389,7 @@ def test_thing_ThingUpdatedEvent(): # noinspection PyPep8Naming -def test_thing_ConfigStatusInfoEvent(): +def test_thing_ConfigStatusInfoEvent() -> None: data = { 'topic': 'openhab/things/zwave:device:gehirn:node29/config/status', 'payload': '{"configStatusMessages":[{"parameterName":"config_11_2","type":"PENDING"}]}', @@ -400,6 +400,6 @@ def test_thing_ConfigStatusInfoEvent(): @pytest.mark.parametrize('cls', [*EVENT_LIST]) -def test_every_event_has_name(cls): +def test_every_event_has_name(cls) -> None: # this test ensure that alle events have a name parameter assert 'name' in inspect.getfullargspec(cls).annotations diff --git a/tests/test_openhab/test_events/test_oh_filters.py b/tests/test_openhab/test_events/test_oh_filters.py index 46f0b605..a061240c 100644 --- a/tests/test_openhab/test_events/test_oh_filters.py +++ b/tests/test_openhab/test_events/test_oh_filters.py @@ -10,7 +10,7 @@ ) -def test_class_annotations(): +def test_class_annotations() -> None: """EventFilter relies on the class annotations, so we test that every event has those""" exclude = ( @@ -23,7 +23,7 @@ def test_class_annotations(): ) -def test_oh_filters(): +def test_oh_filters() -> None: f = ItemStateUpdatedEventFilter(value=1) assert f.event_class is ItemStateUpdatedEvent diff --git a/tests/test_openhab/test_helpers/test_table.py b/tests/test_openhab/test_helpers/test_table.py index 8a1bf69a..ceb0d667 100644 --- a/tests/test_openhab/test_helpers/test_table.py +++ b/tests/test_openhab/test_helpers/test_table.py @@ -1,7 +1,7 @@ from HABApp.openhab.definitions.helpers.log_table import Column, Table -def test_col(): +def test_col() -> None: col = Column('my heading', wrap=40) col.add(['asdf', 'def', '23456trhrethtre', 'ghdrhtrezertztre', 'adfsdsf']) assert col.get_lines(0) == 2 @@ -11,7 +11,7 @@ def test_col(): ] -def test_table(): +def test_table() -> None: table = Table('my heading') c1 = table.add_column('col1') c2 = table.add_column('col2') @@ -49,7 +49,7 @@ def test_table(): ] -def test_wrap(): +def test_wrap() -> None: table = Table('my heading') c1 = table.add_column('col1') c2 = table.add_column('col2', wrap=20) diff --git a/tests/test_openhab/test_interface_sync.py b/tests/test_openhab/test_interface_sync.py index 73949bba..436d5d26 100644 --- a/tests/test_openhab/test_interface_sync.py +++ b/tests/test_openhab/test_interface_sync.py @@ -1,5 +1,5 @@ +from collections.abc import Callable from datetime import datetime -from typing import Callable import pytest @@ -28,7 +28,7 @@ @pytest.mark.parametrize('func', [ getattr(HABApp.openhab.interface_sync, i) for i in dir(HABApp.openhab.interface_sync) if i[0] != '_' ]) -def test_all_imported(func: Callable): +def test_all_imported(func: Callable) -> None: assert func.__name__ in globals(), f'"{func.__name__}" not imported!' @@ -50,7 +50,7 @@ def test_all_imported(func: Callable): (remove_link, ('item', 'channel')), (create_link, ('item', 'channel', {})), )) -async def test_item_has_name(func, args): +async def test_item_has_name(func, args) -> None: async_context.set('Test') if func not in (post_update, send_command): diff --git a/tests/test_openhab/test_item_to_reg.py b/tests/test_openhab/test_item_to_reg.py index 33edd422..48f15756 100644 --- a/tests/test_openhab/test_item_to_reg.py +++ b/tests/test_openhab/test_item_to_reg.py @@ -3,7 +3,7 @@ from HABApp.openhab.items import StringItem -def test_get_members(monkeypatch, clean_objs, ir: ItemRegistry): +def test_get_members(monkeypatch, clean_objs, ir: ItemRegistry) -> None: a = ir.add_item(StringItem('a', 1)) b = ir.add_item(StringItem('b', 'asdf')) c = ir.add_item(StringItem('c', (1, 2))) diff --git a/tests/test_openhab/test_items/test_all.py b/tests/test_openhab/test_items/test_all.py index 2d394c2f..470f8b12 100644 --- a/tests/test_openhab/test_items/test_all.py +++ b/tests/test_openhab/test_items/test_all.py @@ -1,6 +1,6 @@ import inspect from datetime import datetime -from typing import Any, Optional, Tuple, Union +from typing import Any import pytest @@ -28,14 +28,14 @@ @pytest.mark.parametrize('cls', (c for c in item_dict.values())) -def test_argspec_from_oh(cls): +def test_argspec_from_oh(cls) -> None: target_spec = inspect.getfullargspec(OpenhabItem.from_oh) current_spec = inspect.getfullargspec(cls.from_oh) assert current_spec == target_spec @pytest.mark.parametrize('cls', tuple(c for c in item_dict.values()) + (Thing, )) -def test_set_name(cls): +def test_set_name(cls) -> None: # test that we can set the name properly c = cls('asdf') @@ -47,13 +47,13 @@ def test_set_name(cls): @pytest.mark.parametrize('cls', (c for c in item_dict.values())) -def test_conditional_function_call_signature(cls): +def test_conditional_function_call_signature(cls) -> None: assert_same_signature(Item.post_value_if, cls.post_value_if) assert_same_signature(Item.post_value_if, cls.oh_post_update_if) @pytest.mark.parametrize('cls', (c for c in item_dict.values())) -def test_doc_ivar(cls): +def test_doc_ivar(cls) -> None: correct_hints = { StringItem: {'value': str}, @@ -61,13 +61,13 @@ def test_doc_ivar(cls): ContactItem: {'value': str}, PlayerItem: {'value': str}, - NumberItem: {'value': Union[int, float]}, - RollershutterItem: {'value': Union[int, float]}, - DimmerItem: {'value': Union[int, float]}, + NumberItem: {'value': int | float}, + RollershutterItem: {'value': int | float}, + DimmerItem: {'value': int | float}, - ColorItem: {'value': Tuple[float, float, float]}, - CallItem: {'value': Tuple[str, ...]}, - LocationItem: {'value': Optional[Tuple[float, float, Optional[float]]]}, + ColorItem: {'value': tuple[float, float, float]}, + CallItem: {'value': tuple[str, ...]}, + LocationItem: {'value': tuple[float, float, float | None] | None}, DatetimeItem: {'value': datetime}, ImageItem: {'value': bytes}, diff --git a/tests/test_openhab/test_items/test_call.py b/tests/test_openhab/test_items/test_call.py index 7e9f8fc0..99b127ff 100644 --- a/tests/test_openhab/test_items/test_call.py +++ b/tests/test_openhab/test_items/test_call.py @@ -4,7 +4,7 @@ from HABApp.openhab.map_items import map_item -def test_call_set_value(): +def test_call_set_value() -> None: call = CallItem('my_call_item') call.set_value('03018,2722720') @@ -15,7 +15,7 @@ def test_call_set_value(): assert call.value == ('a', 'b') -def test_call_map(): +def test_call_map() -> None: call = map_item( 'my_call_item', 'Call', 'my_value', label='l', tags=frozenset(), groups=frozenset(), metadata=None,) assert isinstance(call, CallItem) diff --git a/tests/test_openhab/test_items/test_commands.py b/tests/test_openhab/test_items/test_commands.py index 3977c6ed..399b386b 100644 --- a/tests/test_openhab/test_items/test_commands.py +++ b/tests/test_openhab/test_items/test_commands.py @@ -10,10 +10,10 @@ @pytest.mark.parametrize('cls', [cls for cls in item_dict.values() if issubclass(cls, OnOffCommand)]) -def test_OnOff(cls): +def test_OnOff(cls) -> None: c = cls('item_name') assert not c.is_on() - if not __version__.startswith('24.08.1'): + if not __version__.startswith('24.11.0'): assert not c.is_off() c.set_value(OnOffValue('ON')) @@ -27,7 +27,7 @@ def test_OnOff(cls): @pytest.mark.parametrize('cls', [cls for cls in item_dict.values() if issubclass(cls, UpDownCommand)]) -def test_UpDown(cls): +def test_UpDown(cls) -> None: c = cls('item_name') c.set_value(UpDownValue('UP')) assert c.is_up() @@ -40,7 +40,7 @@ def test_UpDown(cls): @pytest.mark.parametrize('cls', (ContactItem, )) -def test_OpenClosed(cls: typing.Type[ContactItem]): +def test_OpenClosed(cls: typing.Type[ContactItem]) -> None: c = cls('item_name') assert not c.is_closed() assert not c.is_open() diff --git a/tests/test_openhab/test_items/test_contact.py b/tests/test_openhab/test_items/test_contact.py index 638e4661..0aaa8c7a 100644 --- a/tests/test_openhab/test_items/test_contact.py +++ b/tests/test_openhab/test_items/test_contact.py @@ -5,7 +5,7 @@ from HABApp.openhab.items import ContactItem -def test_send_command(): +def test_send_command() -> None: c = ContactItem('item_name') with pytest.raises(SendCommandNotSupported) as e: @@ -14,7 +14,7 @@ def test_send_command(): assert str(e.value) == 'ContactItem does not support send command! See openHAB documentation for details.' -def test_switch_set_value(): +def test_switch_set_value() -> None: ContactItem('').set_value(None) ContactItem('').set_value('OPEN') ContactItem('').set_value('CLOSED') diff --git a/tests/test_openhab/test_items/test_dimmer.py b/tests/test_openhab/test_items/test_dimmer.py index fee6a32b..b70ef472 100644 --- a/tests/test_openhab/test_items/test_dimmer.py +++ b/tests/test_openhab/test_items/test_dimmer.py @@ -4,7 +4,7 @@ from HABApp.openhab.items import DimmerItem -def test_dimmer_item_bool(): +def test_dimmer_item_bool() -> None: with pytest.raises(ItemValueIsNoneError): assert not DimmerItem('asdf') @@ -12,7 +12,7 @@ def test_dimmer_item_bool(): assert DimmerItem('asdf', 1) -def test_dimmer_set_value(): +def test_dimmer_set_value() -> None: DimmerItem('').set_value(None) DimmerItem('').set_value(0) DimmerItem('').set_value(100) diff --git a/tests/test_openhab/test_items/test_group_handling.py b/tests/test_openhab/test_items/test_group_handling.py index bfedeb02..6422b288 100644 --- a/tests/test_openhab/test_items/test_group_handling.py +++ b/tests/test_openhab/test_items/test_group_handling.py @@ -2,7 +2,7 @@ from HABApp.openhab.items import GroupItem, StringItem -def test_item_group_members_sorted(): +def test_item_group_members_sorted() -> None: add_to_registry(StringItem('d_str', initial_value='val_9', groups=frozenset(['grp_1']))) add_to_registry(StringItem('a_str', groups=frozenset(['grp_1']))) add_to_registry(StringItem('b_str', groups=frozenset(['grp_1']))) diff --git a/tests/test_openhab/test_items/test_image.py b/tests/test_openhab/test_items/test_image.py index e47bd1b6..accad1b0 100644 --- a/tests/test_openhab/test_items/test_image.py +++ b/tests/test_openhab/test_items/test_image.py @@ -2,7 +2,7 @@ from HABApp.openhab.map_items import map_item -def test_image_load(): +def test_image_load() -> None: i = map_item( 'localCurrentConditionIcon', diff --git a/tests/test_openhab/test_items/test_mapping.py b/tests/test_openhab/test_items/test_mapping.py index 03e0de36..22e9cfd1 100644 --- a/tests/test_openhab/test_items/test_mapping.py +++ b/tests/test_openhab/test_items/test_mapping.py @@ -2,8 +2,8 @@ from functools import partial import pytest -from eascheduler.const import local_tz from immutables import Map +from whenever import SystemDateTime from HABApp.openhab.items import DatetimeItem, NumberItem from HABApp.openhab.items.base_item import MetaData @@ -12,12 +12,12 @@ @pytest.mark.ignore_log_errors() -def test_exception(eb: TestEventBus): +def test_exception(eb: TestEventBus) -> None: eb.allow_errors = True assert map_item('test', 'Number', 'asdf', 'my_label', frozenset(), frozenset(), {}) is None -def test_metadata(): +def test_metadata() -> None: make_number = partial(map_item, 'test', 'Number', None, 'my_label', frozenset(), frozenset()) item = make_number({'ns1': {'value': 'v1'}}) @@ -38,7 +38,7 @@ def test_metadata(): assert item.metadata['ns2'].config == Map() -def test_number_unit_of_measurement(): +def test_number_unit_of_measurement() -> None: make_item = partial(map_item, label='l', tags=frozenset(), groups=frozenset(), metadata={'unit': {'value': '°C'}}) metadata = Map(unit=MetaData('°C')) assert make_item('test1', 'Number:Length', '1.0 m', ) == NumberItem('test', 1, metadata=metadata) @@ -50,10 +50,13 @@ def test_number_unit_of_measurement(): assert make_item('test7', 'Number:Angle', '7.0 °', ) == NumberItem('test', 7, metadata=metadata) -def test_datetime(): - offset_str = datetime(2022, 6, 15, tzinfo=local_tz).isoformat()[-6:].replace(':', '') +def test_datetime() -> None: + + # We have to build the offset str dynamically otherwise we will fail during CI because it's in another timezone + offset_str = SystemDateTime(2022, 6, 15).format_common_iso()[-6:].replace(':', '') def get_dt(value: str): + assert value.startswith('2022-06-15') # Date must match with offset_str return map_item( 'test1', 'DateTime', f'{value}{offset_str}', label='', tags=frozenset(), groups=frozenset(), metadata={}) diff --git a/tests/test_openhab/test_items/test_number.py b/tests/test_openhab/test_items/test_number.py index a9b75a51..9dc0f335 100644 --- a/tests/test_openhab/test_items/test_number.py +++ b/tests/test_openhab/test_items/test_number.py @@ -6,12 +6,12 @@ from HABApp.openhab.items.base_item import MetaData -def test_number_item_unit(): +def test_number_item_unit() -> None: assert NumberItem('test', 1).unit is None assert NumberItem('test', 1, metadata=Map(unit=MetaData('°C'))).unit == '°C' -def test_number_item_bool(): +def test_number_item_bool() -> None: with pytest.raises(ItemValueIsNoneError): assert not NumberItem('asdf') @@ -19,7 +19,7 @@ def test_number_item_bool(): assert NumberItem('asdf', 1) -def test_number_set_value(): +def test_number_set_value() -> None: NumberItem('').set_value(None) NumberItem('').set_value(1) NumberItem('').set_value(-3.3) diff --git a/tests/test_openhab/test_items/test_rollershutter.py b/tests/test_openhab/test_items/test_rollershutter.py index a6b0938c..02ec89c9 100644 --- a/tests/test_openhab/test_items/test_rollershutter.py +++ b/tests/test_openhab/test_items/test_rollershutter.py @@ -4,7 +4,7 @@ from HABApp.openhab.items import RollershutterItem -def test_dimmer_set_value(): +def test_dimmer_set_value() -> None: RollershutterItem('').set_value(None) RollershutterItem('').set_value(0) RollershutterItem('').set_value(100) diff --git a/tests/test_openhab/test_items/test_switch.py b/tests/test_openhab/test_items/test_switch.py index 6bc7c9f1..17ab09ee 100644 --- a/tests/test_openhab/test_items/test_switch.py +++ b/tests/test_openhab/test_items/test_switch.py @@ -4,7 +4,7 @@ from HABApp.openhab.items import SwitchItem -def test_switch_item_bool(): +def test_switch_item_bool() -> None: with pytest.raises(ItemValueIsNoneError): assert SwitchItem('test') @@ -12,7 +12,7 @@ def test_switch_item_bool(): assert SwitchItem('test', 'ON') -def test_switch_set_value(): +def test_switch_set_value() -> None: SwitchItem('').set_value(None) SwitchItem('').set_value('ON') SwitchItem('').set_value('OFF') diff --git a/tests/test_openhab/test_items/test_thing.py b/tests/test_openhab/test_items/test_thing.py index 7775091f..0bc09160 100644 --- a/tests/test_openhab/test_items/test_thing.py +++ b/tests/test_openhab/test_items/test_thing.py @@ -1,9 +1,9 @@ -from typing import Any, Dict, List +from typing import Any from unittest.mock import Mock import pytest from immutables import Map -from pendulum import UTC, DateTime, set_test_now +from whenever import Instant, patch_current_time import HABApp from HABApp.core.internals import ItemRegistry @@ -15,13 +15,9 @@ @pytest.fixture(scope='function') def test_thing(ir: ItemRegistry): - set_test_now(DateTime(2000, 1, 1, tzinfo=UTC)) - thing = HABApp.openhab.items.Thing('test_thing') - - yield thing - - set_test_now() - + with patch_current_time(Instant.from_utc(2000, 1, 1), keep_ticking=False): + thing = HABApp.openhab.items.Thing('test_thing') + yield thing def get_status_event(status: str) -> ThingStatusInfoEvent: data = { @@ -34,129 +30,140 @@ def get_status_event(status: str) -> ThingStatusInfoEvent: return event -def test_thing_status_events(test_thing: Thing): +def test_thing_status_events(test_thing: Thing) -> None: assert test_thing.status == 'UNINITIALIZED' # initial set -> update and change - set_test_now(DateTime(2000, 1, 1, 1, tzinfo=UTC)) - test_thing.process_event(get_status_event('ONLINE')) - assert test_thing.status == 'ONLINE' - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + instant1 = Instant.from_utc(2000, 1, 1, 1) + with patch_current_time(instant1, keep_ticking=False): + test_thing.process_event(get_status_event('ONLINE')) + assert test_thing.status == 'ONLINE' + assert test_thing._last_update.instant == instant1 + assert test_thing._last_change.instant == instant1 # second set -> update - set_test_now(DateTime(2000, 1, 1, 2, tzinfo=UTC)) - test_thing.process_event(get_status_event('ONLINE')) - assert test_thing.status == 'ONLINE' - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 2, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + instant2 = Instant.from_utc(2000, 1, 1, 2) + with patch_current_time(instant2, keep_ticking=False): + test_thing.process_event(get_status_event('ONLINE')) + assert test_thing.status == 'ONLINE' + assert test_thing._last_update.instant == instant2 + assert test_thing._last_change.instant == instant1 # third set -> update & change - set_test_now(DateTime(2000, 1, 1, 3, tzinfo=UTC)) - test_thing.process_event(get_status_event('INITIALIZING')) - assert test_thing.status == 'INITIALIZING' - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) + instant3 = Instant.from_utc(2000, 1, 1, 3) + with patch_current_time(instant3, keep_ticking=False): + test_thing.process_event(get_status_event('INITIALIZING')) + assert test_thing.status == 'INITIALIZING' + assert test_thing._last_update.instant == instant3 + assert test_thing._last_change.instant == instant3 -def test_thing_updated_event(test_thing: Thing): +def test_thing_updated_event(test_thing: Thing) -> None: class MyThingUpdatedEvent(ThingUpdatedEvent): def __init__(self, name: str = '', thing_type: str = '', label: str = '', location='', - channels: List[Dict[str, Any]] = [], - configuration: Dict[str, Any] = {}, properties: Dict[str, str] = {}): + channels: list[dict[str, Any]] = [], + configuration: dict[str, Any] = {}, properties: dict[str, str] = {}) -> None: super().__init__(name, thing_type, label, location, channels, configuration, properties) assert test_thing.properties == Map() assert test_thing.configuration == Map() # initial set of configuration -> update and change - set_test_now(DateTime(2000, 1, 1, 1, tzinfo=UTC)) - test_thing.process_event(MyThingUpdatedEvent(configuration={'a': 'b'})) - assert test_thing.label == '' - assert test_thing.location == '' - assert test_thing.configuration == Map({'a': 'b'}) - assert test_thing.properties == Map() - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + instant1 = Instant.from_utc(2000, 1, 1, 1) + with patch_current_time(instant1, keep_ticking=False): + test_thing.process_event(MyThingUpdatedEvent(configuration={'a': 'b'})) + assert test_thing.label == '' + assert test_thing.location == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map() + assert test_thing._last_update.instant == instant1 + assert test_thing._last_change.instant == instant1 # second set of configuration -> update - set_test_now(DateTime(2000, 1, 1, 2, tzinfo=UTC)) - test_thing.process_event(MyThingUpdatedEvent(configuration={'a': 'b'})) - assert test_thing.label == '' - assert test_thing.location == '' - assert test_thing.configuration == Map({'a': 'b'}) - assert test_thing.properties == Map() - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 2, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 1, tzinfo=UTC) + instant2 = Instant.from_utc(2000, 1, 1, 2) + with patch_current_time(instant2, keep_ticking=False): + test_thing.process_event(MyThingUpdatedEvent(configuration={'a': 'b'})) + assert test_thing.label == '' + assert test_thing.location == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map() + assert test_thing._last_update.instant == instant2 + assert test_thing._last_change.instant == instant1 # initial set of properties-> update and change - set_test_now(DateTime(2000, 1, 1, 3, tzinfo=UTC)) - test_thing.process_event(MyThingUpdatedEvent(configuration={'a': 'b'}, properties={'p': 'prop'})) - assert test_thing.label == '' - assert test_thing.location == '' - assert test_thing.configuration == Map({'a': 'b'}) - assert test_thing.properties == Map({'p': 'prop'}) - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) + instant3 = Instant.from_utc(2000, 1, 1, 3) + with patch_current_time(instant3, keep_ticking=False): + test_thing.process_event(MyThingUpdatedEvent(configuration={'a': 'b'}, properties={'p': 'prop'})) + assert test_thing.label == '' + assert test_thing.location == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.instant == instant3 + assert test_thing._last_change.instant == instant3 # second set of properties-> update - set_test_now(DateTime(2000, 1, 1, 4, tzinfo=UTC)) - test_thing.process_event(MyThingUpdatedEvent(configuration={'a': 'b'}, properties={'p': 'prop'})) - assert test_thing.label == '' - assert test_thing.location == '' - assert test_thing.configuration == Map({'a': 'b'}) - assert test_thing.properties == Map({'p': 'prop'}) - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 4, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 3, tzinfo=UTC) + instant4 = Instant.from_utc(2000, 1, 1, 4) + with patch_current_time(instant4, keep_ticking=False): + test_thing.process_event(MyThingUpdatedEvent(configuration={'a': 'b'}, properties={'p': 'prop'})) + assert test_thing.label == '' + assert test_thing.location == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.instant == instant4 + assert test_thing._last_change.instant == instant3 # initial set of label-> update and change - set_test_now(DateTime(2000, 1, 1, 5, tzinfo=UTC)) - test_thing.process_event(MyThingUpdatedEvent(label='l1', configuration={'a': 'b'}, properties={'p': 'prop'})) - assert test_thing.label == 'l1' - assert test_thing.location == '' - assert test_thing.configuration == Map({'a': 'b'}) - assert test_thing.properties == Map({'p': 'prop'}) - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) + instant5 = Instant.from_utc(2000, 1, 1, 5) + with patch_current_time(instant5, keep_ticking=False): + test_thing.process_event(MyThingUpdatedEvent(label='l1', configuration={'a': 'b'}, properties={'p': 'prop'})) + assert test_thing.label == 'l1' + assert test_thing.location == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.instant == instant5 + assert test_thing._last_change.instant == instant5 # second set of label-> update - set_test_now(DateTime(2000, 1, 1, 6, tzinfo=UTC)) - test_thing.process_event(MyThingUpdatedEvent(label='l1', configuration={'a': 'b'}, properties={'p': 'prop'})) - assert test_thing.label == 'l1' - assert test_thing.location == '' - assert test_thing.configuration == Map({'a': 'b'}) - assert test_thing.properties == Map({'p': 'prop'}) - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 6, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) + instant6 = Instant.from_utc(2000, 1, 5, 6) + with patch_current_time(instant6, keep_ticking=False): + test_thing.process_event(MyThingUpdatedEvent(label='l1', configuration={'a': 'b'}, properties={'p': 'prop'})) + assert test_thing.label == 'l1' + assert test_thing.location == '' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.instant == instant6 + assert test_thing._last_change.instant == instant5 # initial set of location-> update and change - set_test_now(DateTime(2000, 1, 1, 5, tzinfo=UTC)) - test_thing.process_event( - MyThingUpdatedEvent(location='my_loc', label='l1', configuration={'a': 'b'}, properties={'p': 'prop'}) - ) - assert test_thing.label == 'l1' - assert test_thing.location == 'my_loc' - assert test_thing.configuration == Map({'a': 'b'}) - assert test_thing.properties == Map({'p': 'prop'}) - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) + instant7 = Instant.from_utc(2000, 1, 5, 7) + with patch_current_time(instant7, keep_ticking=False): + test_thing.process_event( + MyThingUpdatedEvent(location='my_loc', label='l1', configuration={'a': 'b'}, properties={'p': 'prop'}) + ) + assert test_thing.label == 'l1' + assert test_thing.location == 'my_loc' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.instant == instant7 + assert test_thing._last_change.instant == instant7 # second set of location-> update - set_test_now(DateTime(2000, 1, 1, 6, tzinfo=UTC)) - test_thing.process_event( - MyThingUpdatedEvent(location='my_loc', label='l1', configuration={'a': 'b'}, properties={'p': 'prop'}) - ) - assert test_thing.label == 'l1' - assert test_thing.location == 'my_loc' - assert test_thing.configuration == Map({'a': 'b'}) - assert test_thing.properties == Map({'p': 'prop'}) - assert test_thing._last_update.dt == DateTime(2000, 1, 1, 6, tzinfo=UTC) - assert test_thing._last_change.dt == DateTime(2000, 1, 1, 5, tzinfo=UTC) - - -def test_thing_called_status_event(monkeypatch, ir: ItemRegistry, test_thing: Thing): + instant8 = Instant.from_utc(2000, 1, 5, 8) + with patch_current_time(instant8, keep_ticking=False): + test_thing.process_event( + MyThingUpdatedEvent(location='my_loc', label='l1', configuration={'a': 'b'}, properties={'p': 'prop'}) + ) + assert test_thing.label == 'l1' + assert test_thing.location == 'my_loc' + assert test_thing.configuration == Map({'a': 'b'}) + assert test_thing.properties == Map({'p': 'prop'}) + assert test_thing._last_update.instant == instant8 + assert test_thing._last_change.instant == instant7 + + +def test_thing_called_status_event(monkeypatch, ir: ItemRegistry, test_thing: Thing) -> None: monkeypatch.setattr(process_events_module, 'get_event', lambda x: x) ir.add_item(test_thing) @@ -169,7 +176,7 @@ def test_thing_called_status_event(monkeypatch, ir: ItemRegistry, test_thing: Th test_thing.process_event.assert_called_once_with(event) -def test_thing_called_updated_event(monkeypatch, ir: ItemRegistry, test_thing: Thing): +def test_thing_called_updated_event(monkeypatch, ir: ItemRegistry, test_thing: Thing) -> None: monkeypatch.setattr(process_events_module, 'get_event', lambda x: x) ir.add_item(test_thing) @@ -182,7 +189,7 @@ def test_thing_called_updated_event(monkeypatch, ir: ItemRegistry, test_thing: T test_thing.process_event.assert_called_once_with(event) -def test_thing_handler_add_event(monkeypatch, ir: ItemRegistry): +def test_thing_handler_add_event(monkeypatch, ir: ItemRegistry) -> None: monkeypatch.setattr(process_events_module, 'get_event', lambda x: x) name = 'AddedThing' diff --git a/tests/test_openhab/test_openhab_datatypes.py b/tests/test_openhab/test_openhab_datatypes.py index 45504ff5..0ae03317 100644 --- a/tests/test_openhab/test_openhab_datatypes.py +++ b/tests/test_openhab/test_openhab_datatypes.py @@ -1,18 +1,19 @@ from datetime import datetime import pytest +from whenever import SystemDateTime from HABApp.openhab.items import DatetimeItem, NumberItem from HABApp.openhab.map_values import map_openhab_values -def test_type_none(): +def test_type_none() -> None: assert map_openhab_values('UnDef', '0') is None assert map_openhab_values('Number', 'NULL') is None @pytest.mark.parametrize('value, target', (('0', 0), ('-15', -15), ('55', 55), )) -def test_type_number(value: str, target: int): +def test_type_number(value: str, target: int) -> None: ret = NumberItem._state_from_oh_str(value) assert ret == target assert isinstance(ret, int) @@ -25,7 +26,7 @@ def test_type_number(value: str, target: int): @pytest.mark.parametrize( 'value, target', (('0.0', 0), ('-99.99', -99.99), ('99.99', 99.99), ('0', 0), ('-15', -15), ('55', 55), ) ) -def test_type_decimal(value: str, target: int): +def test_type_decimal(value: str, target: int) -> None: ret = NumberItem._state_from_oh_str(value) assert ret == target assert type(ret) is target.__class__ @@ -38,12 +39,7 @@ def test_type_decimal(value: str, target: int): def __get_dt_parms(): # We have to build the offset str dynamically otherwise we will fail during CI because it's in another timezone - now = datetime.now() - offset_secs = int(now.astimezone().tzinfo.utcoffset(now).total_seconds()) - hours = offset_secs // 3600 - minutes = (offset_secs - 3600 * hours) // 60 - assert offset_secs - hours * 3600 - minutes * 60 == 0 - offset_str = f'{hours:02d}:{minutes:02d}' + offset_str = SystemDateTime(2023, 6, 17).format_common_iso()[-5:].replace(':', '') return ( pytest.param(f'2023-06-17T15:31:04.754673068+{offset_str}', datetime(2023, 6, 17, 15, 31, 4, 754673), id='T1'), @@ -52,13 +48,13 @@ def __get_dt_parms(): ) -@pytest.mark.parametrize('value, target', __get_dt_parms()) -def test_type_datetime(value: str, target: datetime): +@pytest.mark.parametrize(('value', 'target'), __get_dt_parms()) +def test_type_datetime(value: str, target: datetime) -> None: assert DatetimeItem._state_from_oh_str(value) == target assert map_openhab_values('DateTime', value) == target -def test_quantity(): +def test_quantity() -> None: q = map_openhab_values('Quantity', '0.0 °C') assert q.value == 0.0 assert q.unit == '°C' diff --git a/tests/test_openhab/test_plugins/test_broken_links.py b/tests/test_openhab/test_plugins/test_broken_links.py index 37032334..a268c8cf 100644 --- a/tests/test_openhab/test_plugins/test_broken_links.py +++ b/tests/test_openhab/test_plugins/test_broken_links.py @@ -13,14 +13,14 @@ async def _mock_things() -> list[ThingResp]: return [ ThingResp( - uid='thing_type:uid', thing_type='thing_type', - status=ThingStatusResp(status='ONLINE', detail='ONLINE'), + UID='thing_type:uid', thingTypeUID='thing_type', + statusInfo=ThingStatusResp(status='ONLINE', statusDetail='NONE'), editable=False, channels=[ - ChannelResp(uid='thing_type:uid:channel1', id='channel1', channel_type='channel1_type', - item_type='String', kind='STATE', linked_items=[]), - ChannelResp(uid='thing_type:uid:channel2', id='channel2', channel_type='channel2_type', - item_type='String', kind='STATE', linked_items=[]) + ChannelResp(uid='thing_type:uid:channel1', id='channel1', channelTypeUID='channel1_type', + itemType='String', kind='STATE', linkedItems=[]), + ChannelResp(uid='thing_type:uid:channel2', id='channel2', channelTypeUID='channel2_type', + itemType='String', kind='STATE', linkedItems=[]) ] ) ] @@ -28,14 +28,14 @@ async def _mock_things() -> list[ThingResp]: async def _mock_links() -> list[ItemChannelLinkResp]: return [ - ItemChannelLinkResp(item='item1', channel='thing_type:uid:channel1', editable=True), # okay - ItemChannelLinkResp(item='item2', channel='thing_type:uid:channel1', editable=True), # item does not exist - ItemChannelLinkResp(item='item1', channel='thing_type:uid:channel3', editable=True), # channel does not exist - ItemChannelLinkResp(item='item1', channel='other_thing:uid:channel1', editable=True), # thing does not exist + ItemChannelLinkResp(itemName='item1', channelUID='thing_type:uid:channel1', editable=True), # okay + ItemChannelLinkResp(itemName='item2', channelUID='thing_type:uid:channel1', editable=True), # item does not exist + ItemChannelLinkResp(itemName='item1', channelUID='thing_type:uid:channel3', editable=True), # channel does not exist + ItemChannelLinkResp(itemName='item1', channelUID='other_thing:uid:channel1', editable=True), # thing does not exist ] -async def test_link_warning(monkeypatch, ir: ItemRegistry, test_logs): +async def test_link_warning(monkeypatch, ir: ItemRegistry, test_logs) -> None: monkeypatch.setattr(plugin_module, 'async_get_things', _mock_things) monkeypatch.setattr(plugin_module, 'async_get_links', _mock_links) diff --git a/tests/test_openhab/test_plugins/test_load_items.py b/tests/test_openhab/test_plugins/test_load_items.py index 407b7754..6b7221ae 100644 --- a/tests/test_openhab/test_plugins/test_load_items.py +++ b/tests/test_openhab/test_plugins/test_load_items.py @@ -1,15 +1,12 @@ import logging -from json import dumps -from typing import List -import msgspec.json -from pendulum import DateTime +from whenever import Instant import HABApp.openhab.connection.plugins.load_items as load_items_module from HABApp.core.internals import ItemRegistry from HABApp.openhab.connection.connection import OpenhabContext from HABApp.openhab.connection.plugins import LoadOpenhabItemsPlugin -from HABApp.openhab.definitions.rest import ItemResp, ShortItemResp, ThingResp +from HABApp.openhab.definitions.rest import ItemRespList, ShortItemResp, ThingResp from HABApp.openhab.definitions.rest.things import ThingStatusResp from HABApp.openhab.items import Thing @@ -57,13 +54,13 @@ async def _mock_get_all_items(): }, ] - return msgspec.json.decode(dumps(resp), type=List[ItemResp]) + return ItemRespList.validate_python(resp) async def _mock_get_all_items_state(): return [ - ShortItemResp('Number:Length', 'ItemLength', '5 m'), - ShortItemResp('Number', 'ItemPlain', '3.14') + ShortItemResp(type='Number:Length', name='ItemLength', state='5 m'), + ShortItemResp(type='Number', name='ItemPlain', state='3.14') ] @@ -75,7 +72,7 @@ async def _mock_raise(): raise ValueError() -async def test_item_sync(monkeypatch, ir: ItemRegistry, test_logs): +async def test_item_sync(monkeypatch, ir: ItemRegistry, test_logs) -> None: monkeypatch.setattr(load_items_module, 'async_get_items', _mock_get_all_items) monkeypatch.setattr(load_items_module, 'async_get_all_items_state', _mock_get_all_items_state) monkeypatch.setattr(load_items_module, 'async_get_things', _mock_get_empty) @@ -95,7 +92,7 @@ async def test_item_sync(monkeypatch, ir: ItemRegistry, test_logs): 'Item ItemLength is a UoM item but "unit" is not found in item metadata') -async def test_thing_sync(monkeypatch, ir: ItemRegistry, test_logs): +async def test_thing_sync(monkeypatch, ir: ItemRegistry, test_logs) -> None: monkeypatch.setattr(load_items_module, 'async_get_items', _mock_get_empty) monkeypatch.setattr(load_items_module, 'async_get_all_items_state', _mock_raise) @@ -107,14 +104,14 @@ async def _mock_ret(): monkeypatch.setattr(load_items_module, 'async_get_things', _mock_ret) t1 = ThingResp( - uid='thing_1', thing_type='thing_type_1', editable=True, status=ThingStatusResp( - status='ONLINE', detail='NONE', description='' + UID='thing_1', thingTypeUID='thing_type_1', editable=True, statusInfo=ThingStatusResp( + status='ONLINE', statusDetail='NONE', description='' ) ) t2 = ThingResp( - uid='thing_2', thing_type='thing_type_2', editable=True, status=ThingStatusResp( - status='OFFLINE', detail='NONE', description='' + UID='thing_2', thingTypeUID='thing_type_2', editable=True, statusInfo=ThingStatusResp( + status='OFFLINE', statusDetail='NONE', description='' ) ) @@ -129,7 +126,7 @@ async def _mock_ret(): assert isinstance(ir_thing, Thing) assert ir_thing.status_description == '' - ir_thing._last_update.set(DateTime(2001, 1, 1)) + ir_thing._last_update.set(Instant.from_utc(2001, 1, 1)) t2.status.description = 'asdf' # sync state diff --git a/tests/test_openhab/test_plugins/test_thing/test_errors.py b/tests/test_openhab/test_plugins/test_thing/test_errors.py index 3a63bce2..6dc3586f 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_errors.py +++ b/tests/test_openhab/test_plugins/test_thing/test_errors.py @@ -4,7 +4,7 @@ from tests.helpers import LogCollector, MockFile, TestEventBus -async def test_errors(test_logs: LogCollector, eb: TestEventBus): +async def test_errors(test_logs: LogCollector, eb: TestEventBus) -> None: eb.allow_errors = True cfg = TextualThingConfigPlugin() diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_format.py b/tests/test_openhab/test_plugins/test_thing/test_file_format.py index 7ee6ea22..f0afa798 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_format.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_format.py @@ -4,14 +4,14 @@ from tests.helpers import TestEventBus -def test_cfg_optional(): +def test_cfg_optional() -> None: assert validate_cfg({ 'test': True, 'filter': {}, }) -def test_thing_cfg_types(): +def test_thing_cfg_types() -> None: assert validate_cfg({ 'test': True, 'filter': {}, @@ -24,13 +24,13 @@ def test_thing_cfg_types(): @pytest.mark.ignore_log_errors() -def test_cfg_err(eb: TestEventBus): +def test_cfg_err(eb: TestEventBus) -> None: eb.allow_errors = True assert None is validate_cfg({'test': True, 'filter1': {}}, 'filename') assert None is validate_cfg({'test': True, 'filter1': {}}) -def test_cfg_multiple_filters(): +def test_cfg_multiple_filters() -> None: a = validate_cfg({ 'test': True, 'filter': {'thing_type': 'bla'}, @@ -49,7 +49,7 @@ def test_cfg_multiple_filters(): assert str(a) == str(b) -def test_cfg_item_builder(): +def test_cfg_item_builder() -> None: c = validate_cfg({ 'test': True, 'filter': {}, @@ -73,7 +73,7 @@ def test_cfg_item_builder(): assert a[0].name == 'replaced_uid' -def test_item_cfg(): +def test_item_cfg() -> None: c = UserItemCfg.model_validate({ 'type': 'Switch', diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py index ffbc32d8..f4356755 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_builder.py @@ -7,7 +7,7 @@ ) -def test_value_formatter(): +def test_value_formatter() -> None: b = ValueFormatterBuilder('test', '"{:s}"') # obj access @@ -25,7 +25,7 @@ class TestDataEmpty: assert f.value == '' -def test_multiple_value_formatter(): +def test_multiple_value_formatter() -> None: b = MultipleValueFormatterBuilder('test', '"{:s}"', '({:s})') class TestData1: @@ -43,7 +43,7 @@ class TestDataEmpty: assert f.len() == 0 -def test_link_formatter(): +def test_link_formatter() -> None: b = LinkFormatter() class TestData: @@ -68,7 +68,7 @@ class TestData: assert f.value == '' -def test_metadata_formatter(): +def test_metadata_formatter() -> None: b = MetadataFormatter() class TestData: diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py index e88cb90c..addd73d9 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_formatter.py @@ -2,7 +2,7 @@ from HABApp.openhab.connection.plugins.plugin_things.file_writer.formatter_builder import ValueFormatter -def test_scope(): +def test_scope() -> None: assert FormatterScope(field_names=('a', 'd1')).get_lines() == [] s = FormatterScope(field_names=('a', 'b', 'b1', 'c', 'd', 'd1')) @@ -17,7 +17,7 @@ def test_scope(): ] -def test_scope_missing(): +def test_scope_missing() -> None: s = FormatterScope(field_names=('a', 'c')) s.lines = [ {'a': ValueFormatter('val_1a'), 'c': ValueFormatter('val_1 __cc__'), }, @@ -30,7 +30,7 @@ def test_scope_missing(): ] -def test_scope_skip(): +def test_scope_skip() -> None: s = FormatterScope(field_names=('a', 'b', 'b1', 'c', 'd', 'd1'), skip_alignment=('b1', 'd', 'd1')) s.lines = [ {'a': ValueFormatter('val_1a'), 'b': ValueFormatter('val_1_bbbbb'), 'b1': ValueFormatter('{'), @@ -45,7 +45,7 @@ def test_scope_skip(): ] -def test_scope_min_width(): +def test_scope_min_width() -> None: s = FormatterScope(field_names=('a', 'b', 'b1', 'c', 'd', 'd1', ), min_width={'a': 15}) s.lines = [ {'a': ValueFormatter('val_1a'), 'b': ValueFormatter('val_1_bbbbb'), 'c': ValueFormatter('val_1 __cc__'), }, diff --git a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py index ce2226c6..e2bc39e9 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py +++ b/tests/test_openhab/test_plugins/test_thing/test_file_writer/test_writer.py @@ -7,7 +7,7 @@ class MyStringIO(io.StringIO): - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.text = None self.exists = False @@ -15,7 +15,7 @@ def __init__(self, *args, **kwargs): def open(self, *args, **kwargs): return self - def close(self, *args, **kwargs): + def close(self, *args, **kwargs) -> None: self.text = self.getvalue() super().close(*args, **kwargs) @@ -60,12 +60,12 @@ def get_result() -> str: return ret.lstrip('\n') -def test_writer_str(): +def test_writer_str() -> None: writer = ItemsFileWriter().add_items(get_test_objs()) assert writer.generate() == get_result() -def test_no_write(): +def test_no_write() -> None: file = MyStringIO(get_result()) file.exists = True @@ -73,7 +73,7 @@ def test_no_write(): assert not writer.create_file(file) -def test_write_no_exist(): +def test_write_no_exist() -> None: file = MyStringIO(get_result()) file.exists = False @@ -81,7 +81,7 @@ def test_write_no_exist(): assert writer.create_file(file) -def test_write_no_empty_file(): +def test_write_no_empty_file() -> None: writer = ItemsFileWriter().add_items([]) assert not writer.create_file(None) diff --git a/tests/test_openhab/test_plugins/test_thing/test_filter.py b/tests/test_openhab/test_plugins/test_thing/test_filter.py index 3adb54a3..5fbcbadf 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_filter.py +++ b/tests/test_openhab/test_plugins/test_thing/test_filter.py @@ -1,7 +1,7 @@ from HABApp.openhab.connection.plugins.plugin_things.filters import ThingFilter, apply_filters -def test_thing_filter(): +def test_thing_filter() -> None: f = ThingFilter('thing_label', 'asdfasdf') assert f.matches({'label': 'ASDFASDF'}, True) assert f.matches({'label': 'ASDFASDF'}, False) @@ -15,7 +15,7 @@ def test_thing_filter(): assert f.matches({'label': 'asdf1234'}, False) -def test_filters(): +def test_filters() -> None: data = [{'label': '1'}, {'label': '2'}, {'label': 'a'}, {'label': 'b'}, ] assert list(apply_filters([ThingFilter('thing_label', r'\d+')], data, True)) == [{'label': '1'}, {'label': '2'}] assert list(apply_filters([ThingFilter('thing_label', r'\d+')], data, False)) == [{'label': '1'}, {'label': '2'}] diff --git a/tests/test_openhab/test_plugins/test_thing/test_str_builder.py b/tests/test_openhab/test_plugins/test_thing/test_str_builder.py index d2b2cb51..95f14d73 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_str_builder.py +++ b/tests/test_openhab/test_plugins/test_thing/test_str_builder.py @@ -1,16 +1,16 @@ from HABApp.openhab.connection.plugins.plugin_things.str_builder import StrBuilder -def test_accessor(): +def test_accessor() -> None: b = StrBuilder('MyText{thing_location}') assert b.get_str({'thing_location': '1'}) == 'MyText1' -def test_regex(): +def test_regex() -> None: b = StrBuilder(r'MyText{thing_location,(\d+)}') assert b.get_str({'thing_location': 'asdf123asdf'}) == 'MyText123' -def test_regex_replace(): +def test_regex_replace() -> None: b = StrBuilder(r'MyText{thing_location,\w+?(\d+).+,\g<1>456}') assert b.get_str({'thing_location': 'asdf123asdf'}) == 'MyText123456' diff --git a/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py b/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py index d004dd2b..c9b56988 100644 --- a/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py +++ b/tests/test_openhab/test_plugins/test_thing/test_thing_cfg.py @@ -25,7 +25,7 @@ def cfg(): }) -def test_zwave_cfg(cfg: ThingConfigChanger): +def test_zwave_cfg(cfg: ThingConfigChanger) -> None: assert 1 in cfg assert 2 in cfg assert 10 in cfg @@ -33,7 +33,7 @@ def test_zwave_cfg(cfg: ThingConfigChanger): assert 'Group1' in cfg -def test_param_split(cfg: ThingConfigChanger): +def test_param_split(cfg: ThingConfigChanger) -> None: assert 154 in cfg assert cfg.alias[154] == 'config_154_4' @@ -50,7 +50,7 @@ def test_param_split(cfg: ThingConfigChanger): assert cfg.alias['154_000000FF'] == 'config_154_4_000000FF' -def test_set_keys(cfg: ThingConfigChanger): +def test_set_keys(cfg: ThingConfigChanger) -> None: cfg[1] = 5 cfg['wakeup_interval'] = 7200 @@ -61,7 +61,7 @@ def test_set_keys(cfg: ThingConfigChanger): cfg['1'] = 7 -def test_set_wrong_type(cfg: ThingConfigChanger): +def test_set_wrong_type(cfg: ThingConfigChanger) -> None: with raises(ValueError) as e: cfg[1] = 'asdf' assert str(e.value) == "Datatype of parameter '1' must be 'int' but is 'str': 'asdf'" @@ -70,7 +70,7 @@ def test_set_wrong_type(cfg: ThingConfigChanger): cfg['Group1'] = 'asdf' -def test_eval(cfg: ThingConfigChanger): +def test_eval(cfg: ThingConfigChanger) -> None: # This resolves to the default cfg[100] = '$1 * 20 + $10' assert cfg.new == {} diff --git a/tests/test_openhab/test_rest/test_grp_func.py b/tests/test_openhab/test_rest/test_grp_func.py index a17692ac..a7225acc 100644 --- a/tests/test_openhab/test_rest/test_grp_func.py +++ b/tests/test_openhab/test_rest/test_grp_func.py @@ -1,11 +1,7 @@ -from json import dumps - -from msgspec.json import decode - from HABApp.openhab.definitions.rest.items import GroupFunctionResp -def test_or(): +def test_or() -> None: _in = { 'name': 'OR', 'params': [ @@ -13,13 +9,14 @@ def test_or(): 'OFF' ] } - o = decode(dumps(_in), type=GroupFunctionResp) + + o = GroupFunctionResp.model_validate(_in) assert o.name == 'OR' - assert o.params == ['ON', 'OFF'] + assert o.params == ('ON', 'OFF') -def test_eq(): +def test_eq() -> None: _in = {'name': 'EQUALITY'} - o = decode(dumps(_in), type=GroupFunctionResp) + o = GroupFunctionResp.model_validate(_in) assert o.name == 'EQUALITY' - assert o.params == [] + assert o.params == () diff --git a/tests/test_openhab/test_rest/test_items.py b/tests/test_openhab/test_rest/test_items.py index 4f736777..29c1a847 100644 --- a/tests/test_openhab/test_rest/test_items.py +++ b/tests/test_openhab/test_rest/test_items.py @@ -1,9 +1,7 @@ -from msgspec import convert - from HABApp.openhab.definitions.rest.items import CommandOptionResp, ItemResp, StateOptionResp -def test_item_1(): +def test_item_1() -> None: _in = { 'link': 'http://ip:port/rest/items/Item1Name', 'state': 'CLOSED', @@ -28,17 +26,17 @@ def test_item_1(): 'tags': ['Tag1'], 'groupNames': ['Group1', 'Group2'] } - item = convert(_in, type=ItemResp) + item = ItemResp.model_validate(_in) assert item.name == 'Item1Name' assert item.label == 'Item1Label' assert item.state == 'CLOSED' assert item.transformed_state == 'zu' - assert item.tags == ['Tag1'] - assert item.groups == ['Group1', 'Group2'] + assert item.tags == ('Tag1', ) + assert item.groups == ('Group1', 'Group2') -def test_item_2(): +def test_item_2() -> None: d1 = 'DASDING 98.9 (Euro-Hits)' d2 = 'SWR3 95.5 (Top 40/Pop)' @@ -58,7 +56,8 @@ def test_item_2(): 'label': 'Senderliste', 'category': None, 'tags': [], 'groupNames': []} - item = convert(_in, type=ItemResp) + + item = ItemResp.model_validate(_in) assert item.name == 'iSbPlayer_Favorit' assert item.label == 'Senderliste' @@ -68,20 +67,20 @@ def test_item_2(): desc = item.state_description assert desc.pattern == '%s' assert desc.read_only is False - assert desc.options == [StateOptionResp('0', d1), StateOptionResp('1', d2)] + assert desc.options == (StateOptionResp(value='0', label=d1), StateOptionResp(value='1', label=d2)) desc = item.command_description - assert desc.command_options == [CommandOptionResp('0', d1), CommandOptionResp('1', d2)] + assert desc.command_options == (CommandOptionResp(command='0', label=d1), CommandOptionResp(command='1', label=d2)) -def test_group_item(): +def test_group_item() -> None: _in = { 'members': [ { 'link': 'http://ip:port/rest/items/christmasTree', 'state': '100', 'stateDescription': { - 'minimum': 0, 'maximum': 100, 'step': 1, 'pattern': '%d%%', 'readOnly': False, 'options': [] + 'minimum': 0, 'maximum': 100, 'step': 1, 'pattern': '%d%%', 'readOnly': False, 'options': () }, 'type': 'Dimmer', 'name': 'christmasTree', @@ -93,13 +92,13 @@ def test_group_item(): { 'link': 'http://ip:port/rest/items/frontgardenPower', 'state': 'OFF', - 'stateDescription': {'pattern': '%s', 'readOnly': False, 'options': []}, + 'stateDescription': {'pattern': '%s', 'readOnly': False, 'options': ()}, 'type': 'Switch', 'name': 'frontgardenPower', 'label': 'Outside Power', 'category': 'poweroutlet', 'tags': [], - 'groupNames': ['Group1', 'Group2'], + 'groupNames': ('Group1', 'Group2'), } ], 'groupType': 'Switch', @@ -122,10 +121,11 @@ def test_group_item(): 'ALL_TOPICS' ] } - item = convert(_in, type=ItemResp) + + item = ItemResp.model_validate(_in) assert item.name == 'SwitchGroup' assert isinstance(item.members[0], ItemResp) assert item.members[0].name == 'christmasTree' assert item.group_function.name == 'OR' - assert item.group_function.params == ['ON', 'OFF'] + assert item.group_function.params == ('ON', 'OFF') diff --git a/tests/test_openhab/test_rest/test_links.py b/tests/test_openhab/test_rest/test_links.py index 02924b18..8bd63dff 100644 --- a/tests/test_openhab/test_rest/test_links.py +++ b/tests/test_openhab/test_rest/test_links.py @@ -1,21 +1,19 @@ -from msgspec import convert - from HABApp.openhab.definitions.rest import ItemChannelLinkResp -def test_simple(): +def test_simple() -> None: _in = { 'channelUID': 'zwave:device:controller:node15:sensor_luminance', 'configuration': {}, 'itemName': 'ZWaveItem1', 'editable': False, } - o = convert(_in, type=ItemChannelLinkResp) + o = ItemChannelLinkResp.model_validate(_in) assert o.channel == 'zwave:device:controller:node15:sensor_luminance' assert o.item == 'ZWaveItem1' -def test_configuration(): +def test_configuration() -> None: _in = { 'channelUID': 'zwave:device:controller:node15:sensor_luminance', 'configuration': { @@ -25,7 +23,7 @@ def test_configuration(): 'itemName': 'ZWaveItem1', 'editable': False, } - o = convert(_in, type=ItemChannelLinkResp) + o = ItemChannelLinkResp.model_validate(_in) assert o.channel == 'zwave:device:controller:node15:sensor_luminance' assert o.item == 'ZWaveItem1' assert o.configuration == {'profile': 'follow', 'offset': 1} diff --git a/tests/test_openhab/test_rest/test_things.py b/tests/test_openhab/test_rest/test_things.py index aeca77e5..157c7614 100644 --- a/tests/test_openhab/test_rest/test_things.py +++ b/tests/test_openhab/test_rest/test_things.py @@ -1,11 +1,7 @@ -from json import dumps - -from msgspec.json import decode - from HABApp.openhab.definitions.rest.things import ThingResp -def test_thing_summary(): +def test_thing_summary() -> None: _in = { 'statusInfo': { 'status': 'UNINITIALIZED', @@ -17,7 +13,7 @@ def test_thing_summary(): 'thingTypeUID': 'astro:sun' } - thing = decode(dumps(_in), type=ThingResp) + thing = ThingResp.model_validate(_in) assert thing.editable is True assert thing.uid == 'astro:sun:d522ba4b56' @@ -28,7 +24,7 @@ def test_thing_summary(): assert thing.status.detail == 'NONE' -def test_thing_full(): +def test_thing_full() -> None: _in = { 'channels': [ { @@ -93,14 +89,14 @@ def test_thing_full(): 'thingTypeUID': 'astro:sun' } - thing = decode(dumps(_in), type=ThingResp) + thing = ThingResp.model_validate(_in) c0, c1, c2 = thing.channels - assert c0.linked_items == ['LinkedItem1', 'LinkedItem2'] + assert c0.linked_items == ('LinkedItem1', 'LinkedItem2') assert c0.configuration == {'offset': 0} - assert c1.linked_items == [] + assert c1.linked_items == () assert c1.configuration == {} assert thing.status.status == 'UNINITIALIZED' diff --git a/tests/test_openhab/test_rest/test_value_convert.py b/tests/test_openhab/test_rest/test_value_convert.py index da031cc2..50b1f079 100644 --- a/tests/test_openhab/test_rest/test_value_convert.py +++ b/tests/test_openhab/test_rest/test_value_convert.py @@ -1,7 +1,7 @@ from HABApp.openhab.connection.handler import convert_to_oh_type -def test_convert_to_oh_type(): +def test_convert_to_oh_type() -> None: assert convert_to_oh_type(1 / 10 ** 3) == '0.001' assert convert_to_oh_type(1 / 10 ** 6) == '0.000001' assert convert_to_oh_type(1 / 10 ** 9) == '0.000000001' diff --git a/tests/test_openhab/test_transformations/test_base.py b/tests/test_openhab/test_transformations/test_base.py index f3b9bf42..462ef1fe 100644 --- a/tests/test_openhab/test_transformations/test_base.py +++ b/tests/test_openhab/test_transformations/test_base.py @@ -2,7 +2,7 @@ from HABApp.openhab.transformations._map.registry import MapTransformationRegistry -def test_sort(): +def test_sort() -> None: m = MapTransformationRegistry(name='map') m.objs['test.map'] = ({}, None) m.objs['aa.map'] = ({}, None) diff --git a/tests/test_openhab/test_transformations/test_map.py b/tests/test_openhab/test_transformations/test_map.py index 5b589a42..e93ff9d5 100644 --- a/tests/test_openhab/test_transformations/test_map.py +++ b/tests/test_openhab/test_transformations/test_map.py @@ -9,7 +9,7 @@ from HABApp.openhab.transformations._map.registry import MapTransformationRegistry -def test_classes(): +def test_classes() -> None: a = MapTransformation({1: 2}, name='myname') assert str(a) == '' assert a[1] == 2 @@ -26,7 +26,7 @@ def test_classes(): assert str(e.value) == 'Mapping is already defined with a default: "asdf"' -def test_parse_file_default(): +def test_parse_file_default() -> None: file = ''' ON=1 OFF=0 @@ -39,7 +39,7 @@ def test_parse_file_default(): assert m.objs['testobj'] == ({'OFF': '0', 'ON': '1', 'white space': 'using escape'}, 'default') -def test_parse_file_int(): +def test_parse_file_int() -> None: file = ''' ON=1 OFF=0 @@ -51,7 +51,7 @@ def test_parse_file_int(): assert m.objs['testobj'] == ({'OFF': 0, 'ON': 1}, 2) -def test_parse_file_int_keys(): +def test_parse_file_int_keys() -> None: file = ''' 1=asdf 2=qwer @@ -61,7 +61,7 @@ def test_parse_file_int_keys(): assert m.objs['testobj'] == ({1: 'asdf', 2: 'qwer'}, None) -def test_parse_file_int_values(): +def test_parse_file_int_values() -> None: file = ''' 1=6 2=7 diff --git a/tests/test_openhab/test_values.py b/tests/test_openhab/test_values.py index 59442e5a..815076d7 100644 --- a/tests/test_openhab/test_values.py +++ b/tests/test_openhab/test_values.py @@ -18,7 +18,7 @@ (OpenClosedValue, (OpenClosedValue.OPEN, OpenClosedValue.CLOSED)), ] ) -def test_val_same_type(cls, values): +def test_val_same_type(cls, values) -> None: for val in values: assert cls(val).value == val @@ -32,12 +32,12 @@ def test_val_same_type(cls, values): )), ] ) -def test_val_convert(cls, values): +def test_val_convert(cls, values) -> None: for val in values: assert cls(val[0]).value == val[1] -def test_quantity_value(): +def test_quantity_value() -> None: unit_of_dimension = { 'Length': 'm', 'Temperature': '°C', 'Pressure': 'hPa', 'Speed': 'km/h', 'Intensity': 'W/m²', 'Angle': '°', 'Dimensionless': '', @@ -50,7 +50,7 @@ def test_quantity_value(): assert v.unit == unit -def test_raw_type_png(): +def test_raw_type_png() -> None: data = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAPwAAAD8CAYAAABTq8lnAAAACXBIWXMAAAsTAAALEwEAmpwYAAAFIGlUWHRYTUw6Y29tLmFkb2JlLnhtcAAAAAAAPD94cGFja2V0IGJlZ2luPSLvu78iIGlkPSJXNU0wTXBDZWhpSHpyZVN6TlRjemtjOWQiPz4gPHg6eG1wbWV0YSB4bWxuczp4PSJhZG9iZTpuczptZXRhLyIgeDp4bXB0az0iQWRvYmUgWE1QIENvcmUgNS42LWMxNDAgNzkuMTYwNDUxLCAyMDE3LzA1LzA2LTAxOjA4OjIxICAgICAgICAiPiA8cmRmOlJERiB4bWxuczpyZGY9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvMDIvMjItcmRmLXN5bnRheC1ucyMiPiA8cmRmOkRlc2NyaXB0aW9uIHJkZjphYm91dD0iIiB4bWxuczp4bXA9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC8iIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgeG1sbnM6cGhvdG9zaG9wPSJodHRwOi8vbnMuYWRvYmUuY29tL3Bob3Rvc2hvcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RFdnQ9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZUV2ZW50IyIgeG1wOkNyZWF0b3JUb29sPSJBZG9iZSBQaG90b3Nob3AgQ0MgMjAxOCAoTWFjaW50b3NoKSIgeG1wOkNyZWF0ZURhdGU9IjIwMTgtMDgtMTdUMTQ6MTc6NTAtMDQ6MDAiIHhtcDpNb2RpZnlEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjIzLTA0OjAwIiB4bXA6TWV0YWRhdGFEYXRlPSIyMDE4LTA4LTIwVDA3OjM4OjIzLTA0OjAwIiBkYzpmb3JtYXQ9ImltYWdlL3BuZyIgcGhvdG9zaG9wOkNvbG9yTW9kZT0iMyIgcGhvdG9zaG9wOklDQ1Byb2ZpbGU9InNSR0IgSUVDNjE5NjYtMi4xIiB4bXBNTTpJbnN0YW5jZUlEPSJ4bXAuaWlkOjE5OTJjOTljLTYxM2MtNDAzNS1hMzdlLTg0ZTNkMDFmNDczNiIgeG1wTU06RG9jdW1lbnRJRD0ieG1wLmRpZDoxOTkyYzk5Yy02MTNjLTQwMzUtYTM3ZS04NGUzZDAxZjQ3MzYiIHhtcE1NOk9yaWdpbmFsRG9jdW1lbnRJRD0ieG1wLmRpZDoxOTkyYzk5Yy02MTNjLTQwMzUtYTM3ZS04NGUzZDAxZjQ3MzYiPiA8eG1wTU06SGlzdG9yeT4gPHJkZjpTZXE+IDxyZGY6bGkgc3RFdnQ6YWN0aW9uPSJjcmVhdGVkIiBzdEV2dDppbnN0YW5jZUlEPSJ4bXAuaWlkOjE5OTJjOTljLTYxM2MtNDAzNS1hMzdlLTg0ZTNkMDFmNDczNiIgc3RFdnQ6d2hlbj0iMjAxOC0wOC0xN1QxNDoxNzo1MC0wNDowMCIgc3RFdnQ6c29mdHdhcmVBZ2VudD0iQWRvYmUgUGhvdG9zaG9wIENDIDIwMTggKE1hY2ludG9zaCkiLz4gPC9yZGY6U2VxPiA8L3htcE1NOkhpc3Rvcnk+IDwvcmRmOkRlc2NyaXB0aW9uPiA8L3JkZjpSREY+IDwveDp4bXBtZXRhPiA8P3hwYWNrZXQgZW5kPSJyIj8+ZnQQuAAANOZJREFUeJztnXd4FNX6xz+zJZuQBBIIifQEEFCqVAVBqXJRBG4geAGVK4KIFUSuys8uIKhYaNeKIjaQq4gFERCUK1WkqBRD7+lA6m525/fHiBfSdnZ3Znd293yeZx5l855z3mz2u3PmnPe8ryTLMgKBIDwwBdoBgUDgP4TgBYIwQgheIAgjhOAFgjBCCF4gCCOE4AWCMEIIXiAII4TgBYIwQgheIAgjhOAFgjBCCF4gCCOE4AWCMEIIXiAII4TgBYIwQgheIAgjhOAFgjBCCF4gCCMs7gwkSfKHH+GCBUgGUoAaQCwQ8+d/AQouuk4Bh4HjQKmf/RSECGUzWrkVvMBrIoGrgeuBDkAzFKFbPezHCRwFtgNbgS1/XgVaOSoII2RZrvISeEQK8BiwHigGZJ2uEuBb4L4/xxQIKqScnoXgfSYBuAf4Cf0E7u76L/BPIFrn31UQZAjBa0djYB5QSOCEXvY6B8wBGuj4ewuCCCF432kJfISykBZogVd22YG3gCY6vQeCIEEI3nvigFcBB4EXtNqrBJiJshMgCEOE4D1HAsYCmQRewN5eJ4ERWr8xAuMjBO8ZScBKAi9Yra6lQE1N3yGBoSmrZ8mdqMM48OZvwLtAopadRkdHk5ycTL169YiJiaFatWpERyuL60VFRRQVFZGTk8OJEyc4efIk586d03J4UO72twFrtO5YYDzK6lsIvjwSMA145M//9xqr1UqbNm3o3LkznTp1olmzZiQkJHjUx6lTp9iyZQtbtmxh06ZNnDhxwheXLlAKjAMWatGZwLgIwVdNBMpd/R/edmCxWOjevTs33XQTvXr1IioqSjPnAE6ePMnmzZv5/PPP2bx5s6/dPQbM0MAtgUERgq+cOOBz4DpvGickJHDbbbeRlpZGXFychm5Vzr59+1i0aBErVqzAbrd7280c4EHApZljAsMgBF8xccD3QDtPG1522WVMmDCBwYMHExERobVfqsjNzWXJkiUsWrSI7Oxsb7pYAtyKsn8vCCGE4MsTDawCunrSyGazMWbMGMaOHav5tN1bzp49y6xZs1i2bJk3zdcCNwFF2nolCCRC8JcSAXwJ9PWkUZcuXZg+fTr16tXTxysf2bRpE48//jjHjh3ztOl/gGGI6X3IIAR/KYtQprKqsFgs3H///dx5552YTMbOHVJcXMycOXNYuHAhLpdH+n0JmKyTWwI/IwT/P8YDC9Qa16xZk/nz59OuXTv9PNKBNWvWMGnSJEpKSjxpNgZ4RyeXBH5ECF6hE7ABZUrvlpSUFN58803q16+vr1c6sX37dsaPH+9JEE8h0BHYo59XAn8gBA/Vgd1AQzXGrVu35q233qJGjRrlfhaVvZWozE1gslBYuxvF8W00dlU70tPTufPOOzl9+rTaJruAzigHcARBihA8zEVJWOGWZs2a8f7771cgdplae14h5sTXl7yaX+9Gsq94UBsvdeDMmTOMHj2aQ4cOqW3yAjBFR5cEOhPugu+CkpnG7YpbcnIyixcvrjAUtsbBD4g7+G6F7XKb3825Bn/30U39OHr0KEOHDlU7vS9FiU34TVenBLpRVt/GXmrWFgvwBip+5+joaBYsWFCh2G25O4k7tKjStvF/vInt3D5f/NSVhg0b8tJLL6n9IrcA83V2SeBHwknwdwCqHrKff/55UlLK54Y02/Oo/esMkKvY5nKVkrD7WUyO8976qTvdu3fnwQcfVGveA0jTzxuBPwkXwVtRDoq4ZfTo0fTtW1EcjkzCrzMwl7gPXbUUnSHh9xc9dNG/3HXXXfTr10+t+ZP4eHJQYAzCRfCjgUbujOrXr1/pnS/u4GIic7arHjAq8yeqH/lUtX0gmD59OjVrqsqHcSUwVGd3BH4gHARvAaaqMXz66aeJjIws93pk7g5qHHzf44HjD7yN7ezvHrfzFzExMdx3331qzf9PT18E/iEcBP83VNzd+/fvT7du3cq9bi7JIWH3dJQMUR7iKiVh93OGfp4fNmxYhesVFdAGDw8YCYxHOGzLfQYMrspAkiRWrFhB06ZNy/xE5rLtU7Dl7PDJgaKELmS0e86nPvRkzZo13HOPqtCEhShx9pejpMCOR8mIawHy/7yOA/uBI4hDOAEn3PbhawMncFPPbcCAAcyePbvc6zUOvEvcoQ80cSTv8rGcbWTcxe5Ro0axbds2d2Yy6hfvCoGNwDpgBbDTa+cEXhNu+/AjUFG8cdy4ceVei8zZTtyhDzVzJC79HSLPGjd+5d5771Vj5sm3fzWgN/AssAMleOehP18XBIhQF/yN7gxatmxJixYtLnnNXJJNwq8z8Oq5vTJkJ7V2PYfZoXkWWk3o0qULDRuqOl7gLVcCLwIHgAl4XkVXoAGGLhddtCD5elyuf0rQAFn+HZPpK1uydS0qDnRIA9Ijge7u7FJTUy99QXaR8Ot0zPY8L72uHEtJFrV+nUHGVTrnjXSVEpm3k6jMLViLT+O01iC/3t8oqXFFpU0kSWLo0KEVPtpozGUoNfkmo+zve779IfAawz7DF89tcDcS82T50mmkJJGPLH1rguXWyNivgJyK2kt3/tYbWF3VGCaTiY0bN15yOCbuwLvU0Oi5vTLymt7B2WSvE+NWiKk0n6isLURlbiQqawsmZ+GlBpKZ7Csnk1+nT6V9HD161JNgHK34D3A7yoKfQGPK6tuQd3j59WYJJaXFs2VZLvdtI8vEgJzqhFRXyTmnDBskiS8wsxxluniB3u7Gad269SVij8zeprvYQflSKanRiuL41j71Yyk+TVTGT1TL3Ehk3m6QnZUby07i98+nsPY1uCwVV5Vu2LAhKSkpnpym04K/Ay1QdlL+8OfA4YghBV9SWnyNLMvlI2DKIMuYgetkmetw8ZIkSb/JMl+YMS2XJKm9u9lL167/21a2lGST8OvzPvuuij8fG05d/W+c1vLn7KvCdm4fUZkbqZb5E9Z8z4RpcpzHdnYPRbU6VmrTo0cPfwselOf7rSiLrF+7sRX4gCEFL0tSEl7UtZNluSXQ0onz0aPP13Wu2FnEl7uLWLu3mGJH+f6uueaaPxu6qLV7GmbHWR89V4+5JIuEX5/nzFXTqWrxW3LZiczZQbXMn4jK2qQqlr8qTG4WDa+++mree+89n8bwkhrAFygLrd8GwoFwwJCCl3BZfF0fT4g2mf/ZNZp/do2myCGzek8xK3YVsfK3YjLPK1Pf5s2bI7ns1Pr9JWVK7Gcis7dR6/eXyW5xP5j+96cwO84RlbWJqIyfiMr5GclZrNmYUlXTfqBjx45IklRlIdGEhASSk5NJSEigdu3a2Gw2srOzyczM5MyZM6Snp3tbiNSMkiO/G/CrNx0IqsaQgkc2WbQM0oqySgxsE8XANlHIMmw6ZGdvponGJ98hKmsL5pIszcbylJiT3xCZu52iWp3BZMV6dh+RZ39H0y3Bi5BdVQs+NjaWFi1asGfPpensWrZsSe/evbnuuuu48sorq1zMzc3NZf369axbt441a9bgcDg8cbE6SurwLsAZTxoK3GPIVfri+Q0flF3yy34fOAzIbvEA+fVvqtJm+vTpLFq0iOjoaG6++WaGDx9eLlZBLadPn2b+/PksW7YMp7PqL5sybEHZVhXVcHwgKEJri+c1mizLrhf8PnAYkNPiPs7Xv7lKm+3bt3Po0CEGDBigWVWdo0ePMmXKFHbs2OFJs8koefIFXhIUobUSsjEfNUIBV6lbk/bt25OamqppCa2GDRuyaNEibrnlFk+a/R9QSzMnBMYUPLIkBK8T7hbt9CQiIoKnnnqKqVNVpScApcjnk/p5FH4YUvAuSdzh9SKQgr/ArbfeypgxY9Sa3w0019GdsMKQgkeSxMEKvXCzSu8vJk+eXEnuwHJYELnxNcOQgt93yq7rsa2wRnb/DO8PJEni6aefJiYmRo35MJSy3gIfMaLgH171e/GIQDsRqkgGSkJTs2ZNxo8fr8Y0Fqh6a0GgCiMJ3oxSzXWW2WQov0ILFav0/uS2226jTp06akz9fowvFDGKsCJQjkmOB7AYxasQxAiLdhcTERHBgAED1JhWfq5XoBojrIabgQ+4aMpmMRk0j57NhlQ9BslmA4sFyWoBy5/ri6UOZEcplJYil5Qgn8sHz2qy+wWjCR6gX79+vP322+7M6qPkKMzU36PQxQiCf5MyRQ4s5gB5cjG2CEy1a2NKTMAUH4cUG4MUoaqc/F/Idjvy+XxcuXm4MrJwZWZCSYAjRQ0o+DZt2pCYmEhGRoY70ysQgveJQAv+IeCfZV80B+gOL8VEY05uhKluHaS46j7XVpIiIpBq1cRUqyY0bYwMyHnncJ08hfPwEeT8Ai3c9swngz3Dg7Jin5KSokbwTYAf/OBSyBJIwXcHKsw4YfXnHd5ixpzcCHOjBoowdUQCpLjqmOKqY7myOa7sHJxHjuE8fARK/XPnNdIq/cUkJiaqMfMsW4igHIESfBzwSWXj++UZ3mrF3LQJlmZNkGyeTdW1wvTn3d/S8gpK9x/AmX4APDtK6jGyAe/wAElJSWrMngceQcljmI5S8GIbsB44pZtzIUSgBD8TqHQvRtdVepMZc4umWJpfjmQ1RkCfZIvA2voKLC2aUrrvD5x703WLiDPioh0oCUVVYAOS/rzKpuD9HfgY+JBLcxsKLiIQG2DdgLFVGVjM+tzhTZclEnFDb6ytrjSM2C9GslqxtrqSiBt6Y7pM1RTX8zEMKvjsbN9Sd6HkxXsG5c7/KeW/EAQERvCzcVPBpFa0xm5FRGC9phMRPbphijV+hKYpNpqIHt2wXtMJPNwZcIdRC2FoIPiLSQV2o9TCc1tINJzwt+D/BnSuyqB2rJm29bW7+0q14rH17Ym5QX3N+vQX5gb1sfXtiVQrXrM+I87/ganUeCngDx8+rHWXZmA0ynP+cxgnyCyg+PtNqPJss8UsMfeWeCKt2kzpzc2aEtGzB1J08JYzk6KrEdGzB+ZmZSvbetlfaSE1981Fr5x53lBcXMyRI0f06j4CmAp8A+i7DRME+HPRriNKYsJyxNhM3Nw2ikl9YmlZV5u7u6V9WyxNG2vSV6CRTCas7VojxURTut33IqzRp9ZgKTzBuUbDKUrojGwKzC7FBQ4cOOBtlltP6IeS+34IsEvvwYyKPwV/SYBNVISJ/i0jGdahGn9rGanZXR2TCWuXDkE5hXeHpWljJFsEjs0/g8u3/XTb2b3U3vU0LnM1ChO7UpjUk6Ka7S9Jl+0v9u/f76+hGqOUsE4FVvprUCPhr7+uDfhHhEWizxWKyAe2iSI6QuPVeJMJ67VXY75M1Z5uUGJuUB+sVhwbNvksegCTs5CYU6uJObUalzWWwsTuFFzWk+L4tnhWHdp7/Ch4UMpVLwWuJQxr1uuetVZeMsw857PVkyJM0qxB7aoRF6Xfh8h6TaeQvLNXhPPYcRwbt+rXv60mhYk9KEi6npK4lrqNoxZZlsnLyyMjI4PMzEzS09NZt24d27Zto7TUq2Ci4ygLyCEdsOOXNNWyLEul8xp3d0qlw2WZYSinnHQllJ7Z1VKaflCTZ3q340QmUph0HQVJ12Ov3kz38TwhPz+f1atXs2DBAm8W/n4GegCF7gyDFV0Fb5+b3MUlycNlXGnI1PPKQy8wN2uKtZ1vlViDFceO3Tj3p/ttvNJqdSm4rCcFidfjiEn227juKC0t5fPPP+e1115TcwjnYt7CTSBYMKO54O3zkts5cQ2XZIbLyCk+e+ghUq14ZetNXWhmyCG7XNi//wE5O9fvYztikilIup6CpJ6UVqvr9/ErIjc3l4ceeoiffvpJbRMX0J4QfZ7XTPCOeQ2vdcryXBnaauadp0REKIEpQbzPrgVyQSEl330P9sCdtS+Ob0NuswnYY5sEzIcLuFwuXn75Zd588021TdYQohl1NKk8Y1/Q6CqnLK8MqNgBa4e2YS92UIJzrB0C+qcgMncXl/08CUvh8YD6AcpBnIceeoixY1XP1HujlKkOebwSvNPlelgOcNpg02WJYbMirwZzg/q6HbhRi1RaSNzhjwPqw8VMmjSJ/v37qzWfhb/2IQOIV4KXILD7NCYzlqvaBdQFI2K5qh2YApsfzHrOr3vqVSJJEjNnziQlRdXS0pUoSVlCGu9WuuTA7l2aWzQNilNv/sYUG425hTYx997itCUEdPyy2Gw2pkxRXbimXLq1UMM7wZuluRr7oR6rFUvzywM2vNGxNL8cAnjWP7+u6im03+jZsyfXXHONGtObCfFTdV6v0hfPa7hEluVhejhVFeYrWmBtLXIbVIVj9x6ce/b6fdwzMZ34rzWVY8eOcfz48b+us2fPUlhYSFFREUVFRZSUlBAVFUV0dDTVqlUjOjqa6OhoEhMTSUlJ+etKTk7WrGT1xo0b+ec/Vd3AO6IE5IQEZfXtdSy9zRY7vrjkXFd/BthgMWNpFvhtH6NjadYE5x9/6J4YMz3DwY9/lLDliJ0th53sOfUZsvwfVW0LCgooKHCftbd+/fp06dLlr0tl7rtydO7cmbi4OPLy8tyZ9iCEBF8WnwJviuc37IMsr5Jl/6xumps2xto+sNtPwYJj+06c6Qc17dPlktly2M6Xu4tYsauI/Wf8nxAzJSWFrl27cuONN9K+fXuP2k6dOpVly5a5MwupyDvNI+2K5zWaLcuuiT57poKI3tfpnko6VHBl52Bfs16Tvo5kl7Lwp3ze21jA6XPGSXPdsGFDBg0axM0330yDBg3c2n/xxRdqFvA2EEKr9ZoLXv66qa3kUMk2WaaVz95V5UdMNLYBop6gJ5R8vcrrYheyLPPV7mLe+DGf1XuL0T8/hW907tyZsWPH0r175VrdsmULt912m7uu0oGQWRXWJNLuYqQB6SWSZBkpIelaSM2cLHIReoo375ksy3z2SyGdZpxh2BtZfLfH+GIHRcxjx44lNTWV7777rsIMOiqf/2M1d85AaLIFYZtwaBeSNFWLvirDVFdVSWHBRXj6ni3fUUjnGWcY8XY2v53UtyCGXvz222/cd999DBw4kFWrVl3ys/h4VclAQzpWW7M9R9uEw7MlSfpeq/4u7TwCKa66Ll2HMlJcdVBRVeePMw4GzMnglrey+TVIhV6W9PR07r//fu68806OHj0KQE5OjpqmIXs2HjQUvCRJsi0i4jYJKU+rPi9gql079IOcdUBCee8qo8ju4ukVZ+kw/TTf7zNeaWst2LBhAzfeeCNz5szh2LFjapqc19unQKJ5xpuSBcm3uJzOj3xxqizhmM1GKyrLivPLUTu3LszmQKa2W2uxsbG0bt2aJk2aUL9+ferXr0/dunWJjY0lKiqKqKgobDYbRUVFFBYW/rUfn5WVxaFDh/66Dhw4oPaOrJqEhASysrLcmYlVek8pmtdwMbI80uOGlSC247yn7PacLMvM+T6f/1ueh0ODuJzY2Fiuu+46unbtSrt27UhJSfE5D+IFDh48yObNm9m0aRObN29WEzSjBSG9D69L1tpIi+WeYoejO9BQi/6k2BgtuglLLn7vsvOd3LEoh1W/F/vUZ/Xq1bnpppvo168fHTt2xGLRJ/lx48aNady4Mf/4xz+QZZmtW7eyfPlyVq5cqSpKz0t+16tjI6Bb1lrH/OTrnC7nWtnXdQKbjchBA3zqItwpXv41B47nM2h+lk9T+Kuuuorhw4fTv39/IiMjNfTQM4qLi1m9ejUffPABv/zyi9bdh3Qsva5pqovnNZwpy7Lqs4kVjl+7FraePXzpIuxZu+Brhs06SE6Bd1FyXbt25b777uOqq67S2DPf2bx5M//+97/ZuHGjFt1lA4koee5CAr8KXl7SMqIk8/xmWZbbeduHqX5dIrpWWKFKoIKlX/zBqPErsZd6Hj3TqVMnJk6c6HHMeiDYuXMnM2fOZPv27b508y4hdiZe80i7qpDSfrNLZkZKkuT9Q6NOz4fhwNIv/uAf4zwXe61atZg1axbvv/9+UIgdoG3btnzwwQfMmDFDbYBNRajatwtmdD/sbxt/9HdJlp/1tr1kFYL3hv98mc6Iu1bidHkm9uHDh7Ny5UpuvvlmnTzTD0mSGDJkCCtXrmT48OHedPEYSrHJkEX3UlMA8tvNY0uKCvO8WcAzX9Eca+srffYhnFjx7UFS//k1jlL1j6I1atRg2rRp9OkTOtmaV69ezaOPPsr58x7F0hQDNwA/6OOVf/HrlP4C0ph952UJbaMoBBXy884Mbhm30iOxt2/fnuXLl4eU2AH69OnDZ599RuvWHlUligS+ANro41Vg8Yvg5debJUhQy6vGpaER2+0PTp0pYNCtKygsUr/11qtXLxYuXMhll12mo2eBo379+nz44YekpqZ60qwGSjlpTeJIjIRfBF/iLJ7ibVYc2eH/rCrBSFFRKYNuXcGJ0+oDUgYOHMhrr72GzWbT0bPAY7VamTZtGuPGjfOkWR1gCRC4jKA6oLvg7fOTr0aWJ3ndgXelgMOOex75nq071BdRHDFiBLNmzdItSs6ITJo0iUcffdSTJl2A53VyJyDoKnh5XssYl8u1WJbxujqCXBKap7i05D9fprPwoz2q7cePH88TTzyhWcx7MHH77bfzzDPPeNJkEjBQJ3f8jq6CL+HcKzKyT2lm5XP5WrkTkpw6U8C4h9aqtn/44Yd58MEH9XMoCEhLS+OBBx7wpMm7gPukeUGAboIvmZ88WJYZ43tHJcgBrIpqdEbf9x3Zuerimu644w7GjPH9TxIK3H333YwaNUqteU1gno7u+A1dBC/PS77MJTtV1+p12995cZeviA+X7WPVuqOqbK+//nomT56ss0fBxdSpU+ndu7da84EolWmCGl0EX4LzHWQ0KzLmys3TqquQoaDAwZSnN6iybdKkCS+++CImU0hXUfIYSZKYMWMG9eqprqXyKqBNKZwAofknoHh+o3tkmb9p2acrw22WkrBj+itbVW3BWa1WZs+eTUyMyClQEdWrV+eVV15Ru1uRDPyfvh7pi6aCL3k9uQUu1wta9gngyswkCDIl+42Dh8/y0gJ158AnTpxI8+bNdfYouGndurUnjzuTCeKAHM0EL7/ewSo7XB/Iekx5SuzIeec07zZYmfHqNkrs7vNTde7cWW0BxbDn9ttvp21bVWXMIgCfcjwEEs0EX+zIfFpG1u0spetkQEvSG4bjJ8+zaIn7PXeLxcKTTz4Zlnvt3iBJEk899ZTadY4xQFDGImsieMf8Bt0l5H9p0VdlOA8f0bP7oOHFeduxO9wfjBk5ciRNmohKu55wxRVXMHKkqtyrkcBDOrujC77XllvctHrJWftOWZaTNfSrQsI9e21WdhGN2i90ezimZs2afPvtt8TGhnTVJF3Iz8+nb9++5ObmujUFGoGxT4Fqfjy2+Kx9jj/EDuA8EvIJSapk0ZI9qk7CjR07VojdS2JiYhg9erQqU0CVoZHwSfAl8xsOQ5bdluPUCufhI8gl4Rt19+7H7p/d4+LivM32IviTkSNHqv3CvFVvX7TGa8HLC5rVc7nkf2vpjFtKnZTuP+DXIY3Czzsz2L0n263drbfeSrVqIV0PUXdiYmLUht22AzzKrhFovHqGl2VZKpnfaJUsy/5PkWK1YrvpBiRr8B1Tzskt5oeNJ9i9J5t96bnsP5BLZnYR5/MdnM9XZi4x0VZiYyJITIiiWZN4mjWJo23LBJavPOj2RFxERAQ//vgjNWrU8MevE9Lk5uZy3XXXYXd/juNF4GE/uOQVmlSecSxodFtAxA7gcFC67w+srYIjz91ve7NZ/Olevl17lJ2/ZeIup2ROXgk5eSUcOX7eo/PtoKR0EmLXhvj4eHr16sXKlSvdmY4EHgE0KNylP14J3uVCs7px3uDcm465USNMsdGBdKNSSkpKeffjPbyx6Fe2787027hDhoR0wlW/M2jQIDWCr4NSrWaz/h75jnfpTiQ5KaCxri4npb/sIKJHtwA6UZ6iolIWvLuLF+dt51SGf8uMJyYm0q2bsd6PYKd79+7Ex8er2aLrRZAI3stFOyngv5zrdAbOY8cD7cZffPXdIVp2X8xDT27wu9hBmc6L03DaYrFYGDBAVV3Dnnr7ohVefUJsFstzKHW4Aorj553IBf4X18Wcz7cz9I6vuGnkCg4dDVy8f/fuIVPS3FCofF+vRYmxNzxeCV666+BRyUoXSeI/EgQuy6Tdjn3TFmRXYGr/HTiUx9X9l7Dsy8BuFVqtVrp0EfX39KBjx45qZk5RwNV+cMdnvJ4DRt517EDkPcdSbdVsdSWYgMR6KQBVN+XsXEp3/ebvYfl+wzE63/AJv+8PfGRlhw4dxN67TsTExNCqVSs1pkHxjevzQ590R3pm5L3HFkTdc+x6WxQNJJM0Ecm/CxjO/emUph/023gLFu6i37DPyckzRkbdTp06BdqFkObqq1XdvFvo7YcWaJqUXBpz7CTwCvBK0ZuNUkx2ebgsy7fIoOqgsS+Ubt+JZIvA3KC+ruM8+9IWnpi5yev2JpOJ9u3b0717d+rWrUtiYuJfF0BmZuZf17Fjx1i/fj07duzAVcVjS4cOHbz2R+Celi1bqjELiiwjfikmWfJ6cgtKXcNlmVtkZP2+CU0mrNdejfmyJF26//SLP0i78xuPdyQlSaJnz5706dOHnj17elzOODc3l/Xr17N69WrWrVtH6UXFOSwWC1u3biUqKqhTrRma/fv3q6mmmwXU9oM7HlFW334R/MXY/53S1ul03iLJDJeRUzTtHBTRd+mg+Z3+l90ZXHvTpx7VbQNllXfy5MmapZnKzMxk6dKlfPLJJ5w5c4Y2bdqwZMkSTfoWVExJSQnt2rUrJ54KqIXBjssGXPAXY5+b3MWF6xYZ0kCuq2XflvZtsTRtrElfZzIK6dTvY46dVJ8u+8orr2TKlClqn/88xul0snbtWgoKChg8eLAuYwj+R+/evTlx4oQ7sy7AFj+4oxpDCf4CsvyUqXTBu92dsvMRWaa/Vv2amzXF0qYlkg8BKXa7k+sHL2PjttOq26SlpfH4449jDcIDPoKKGT16NJs2uV27uQ/4BPBfPLUbNDk8ozWS9JQLWA+sL57XYJ4sM0GLfp3703FlZxNxdWekaO+2rZ5/bZtqsZtMJh599FFuvTXojkkL3KDyfPycP69cYB/wC/D9n5chcq0bLhbTRvV/SUh5WvUnZ+dS8t33XoXhHjx8lhmvblNla7PZeOutt4TYQ5ToaI8OasWjBOLcjVJyOgPYCTwLXK65cx5gOMFL9/yWj8TPmnZqt+PYuBX7D//FdV59/fRJT/xIcYm6U48zZsyga9eu3nooMDgeCr4sEtAGpYjFfmAjypeB3497Gk7wf+L2eJI3uE5nYP92DY5ff0d2OKq03bE7k+Ur1QXz3H333WoPWQiCFB8FX5argfnAYWAq4LckBoYUvCzL+sXnu5w4f99HyZff4ti9p9IcedNe2aqquz59+nD//fdr6aHAgOi0AJsAPAccQbn72/QY5GIMKXgkSf8DOQ4Hzj17KflqJY7tO3Fl/2/79PDRc/zny3S3XURHR/PMM8+IYg8CX6mB8nz/K3CDngMZYpW+LCZZVlFqQSNKnTjTD+JMP4gUE405uRGLPstwm4oKYMyYMdSsGb558gWa0xRYibLQNx4dHm0NKXhZkkpxH9XkNftOO9h53M7+M6XsO+PgQGYpeUUu8otlzpfso9jhfuzatWuLum0CvUhDCeIZjsaZdAwpeDQW/NkiF1/sLGLdvmK+31/MqbO+zx+sVisLFy6kd+/eojprGNC5c+dyrzkcDgoKCigoKCAnJ4fDhw9z/PjxKg86eUAj4EfgX8DLWnQIBom0K0vxvIavyrLs00qY0yWzZm8x728qYMWuIkp0XBVITk7m9ttvZ8iQIURGRuo3kMDw2O12Dh48yObNm/+6CgrUbwVXwjzgfrzIN2HI0NqyFM9t9JKMa5I3bUudMh9tLWTmt+c4kOnfZDzx8fGMGjWKESNGeHwiThCa2O121q5dy/Lly/nhhx9wOr3OZr0EpdKNR6WXgkPw8xrOlGXZ4xrcizcX8NzXZzmSHdgU4ZGRkaSmpnLXXXf9dc5dIDh16hRvv/02S5cupaTEq+Qpq4CBeCD6oBB80bxGzyG7pqq133vawX0f5bLhgDEy0FygevXqPP744wwcODDQrggMRFZWFnPmzGHJkiVqjtyWZQnwD1RO7zWvHqsP6gJvXC6ZaV+fpdP004YTO8C5c+d4+OGHuf/++8nJMdQxaUEASUhI4Omnn+aTTz7hyis9rqCUBrzm7diGFLxJdh94k3HeyY1zM3nu63OUBiZprWpWrVrFTTfdxOrVqwPtisBAtGnTho8//pjU1FRPm94DTPRmTEMKHpNcZaD7lsMlXP38adbtN95dvTJycnK49957eeGFFwLtisBAREREMG3aNB599FFPC4nMxItMuYbch5dlqZRKMset3lPM8DezKLT7tk9fr149unTpQvPmzUlJSaFBgwZUr16d6OhoTCYTOTk5ZGVlkZOTQ3Z2Nvv27eOnn35i//79Po379ttvU1BQwJNPPilCcgV/cfvtt9OkSRMmTpzI+fPn1TSxoiTbuAoPIvIMuWhXPK/hA7Isv1L29U+3F3LHe9k4vFyEb9asGUOGDKFPnz40aNDAqz6ysrLYuHEjP/zwAytXrsTh5tRdZQwePJhp06ZhNpu9ai8ITQ4dOsS4ceM4duyY2iZLUCLyKiQoVumL5ze6R3a55l782opdRdzyZpaqGPey9O3bl3HjxtG6dWutXATgzJkzLFq0iI8//tir4IobbriBF198UaTCElzCoUOHSEtLU3unB+gPfFvRD4JE8A3HyS759Qv/3niwhAFzMlXFuF/Mtddey5QpU2jWrJnmPl5Mfn4+H330EfPnz6eoqMijtv369eO117xedBWEKBs2bGDcuHFqw3TTgVZAuUWtoNiWk13/W6X/44yD1H9neST2hIQEZs+ezVtvvaW72EEpRzR27FiWL19O+/btPWq7atUqFi5cqJNngmDl2muv5V//+pda86bAw2oMDSl4s6Tsw5c4ZEa+k01uofp9t5YtW7Js2bKAZKBp2LAhixcv5uGHH/Zomv7SSy+xY8cO/RwTBCW33367J1t2k1GROceQgpcxOQD+9Vkeu0+oXxTr27cvixcvJilJn8ozajCZTIwZM4aPP/6YuLg4VW1KS0uZOHEieXl5uvomCD6efPJJtcE5NYB73RkZU/BmV9ZXu4t4/Qf1hR/GjRvHa6+9ZpiSSy1btmTx4sXUrq2u+tCpU6d45JFHvAm1FIQwERERPPXUU2rX0h7ETWJMQwp+9re52x/4JFf15tvUqVOZNGmS4fa1mzZtygcffEDduuqK6qxbt45PP/1UZ68EwUabNm1IS0tTY5oA3FaVgSFX6VHye/2fGsO0tDSeeeYZnd3xjZMnTzJ06FBV8fRJSUmsWrUKm033fIaCICIrK4vevXurOWW3Cbjmwj+CYZU+BZUrjldddRWPP/64zu74Tt26dXnhhRdUfXmeOXOGjz76yA9eCYKJhIQEhg0bpsb0aqoodmFEwT+CinS9SUlJzJkzJ2iCVrp168b48eNV2b7++utaZEkRhBhjxoxRG5lZ6bTeaIKvA9zuzkiSJObMmUNCQoIfXNKOe++9t8LcaGXJzc1l0aJFfvBIEEzUqVOHHj16qDGttJi90QQ/CRV390GDBtGmTRs/uKMtZrOZ6dOnY7G4P7P0zjvvcO7cOT94JQgmBg0apMasNcoCXjmMJPgI4A53Rlarlfvuu88P7uhD/fr1GTp0qFu78+fP8+WXX/rBI0Ew0atXLzVlrySgZ4U/MNAq/SDgc3dGt912G4899pj+3ujImTNn6Nevn9sV1zp16tC8eXMyMjLIzMwkLy+P+Ph4EhMT/7ratWvH9ddfrzrIRxD8TJgwgbVr17ozWwBMMPLhmU+BKuMIq1WrxurVq0Oi2svMmTM1i6E3m8106tSJPn36cOONN4qMuSHOe++9x4wZM9yZbQKuMargqwE5uHl+v/POO5k8ebI//NGdrKwsevTooVXRgr+IiYlh/Pjx3HrrrWIvP0TZu3cvgwcPdmeWC9Q06j58V1Qs1g0ZMsQPrviHhIQEOnbsqHm/+fn5vPjii/Tv358vvvhC8/4Fgadx48Zq0mHFA+Xiuo0i+AoXGC7miiuuoEmTJv7wxW/07dtXt75PnTrFlClTeOSRR7DbPapdIDA4ERER1K9fX41puRpoQSP43r17+8MPv9KvXz/dx/j8888ZPXo0ubmaFyIVBJDk5GQ1ZuWqoBhB8BLQzp2RmoCVYCMpKckv8QTbt29n6NChHD16VPexBP5B5cJ1bNkXjCD4BkCVZ1qtVivt2rXzjzd+plu3bn4Z58SJE9x9993k56s/ciwwLir24sGggndbazklJYWIiAh/+OJ3PE2J5QsHDhxgypQp4sx9COCt4AOVl96KkkS/K0rGzSpJSUnR3aFA0a5dOyRJKidCm81Gt27d6NWrF1dccQW1a9cmLi6O3NxcMjIy2Lt3L9999x0bN270KFX22rVrmTNnDvff71M1bkGQ4k/B21Ci6UahLNLFqG3YsGFDvXwKOLGxsVx++eV/FbhISUlh5MiRDB48mJiY8m9RUlISSUlJtG7dmmHDhpGbm8uCBQv48MMPKS1VVx57wYIF9O/f3y8JPgX6oPI0Zbk81/6Y0icCs4DTKJUyBuKB2AFq1HCbmy+o6dSpE7169eKdd97hm2++YdSoURWKvSLi4+N57LHH+PLLL1ULWJZlXn75ZV9cFgQYbwWv5x0+FngCpfCdT4nmqlWrpolDRmXq1Kme1hUrR3JyMh9++CEPPfQQ69evd2v//fffs337dr+uIQi0Q2U1Yr/d4f8O7EFJnetzVkmjJKbUC1/FfoGYmBjmzp1Lhw4dVNnPnj1bk3EF/ufw4cNqzDLKvqC14E3AbGAZUE+zTjUSRDhgtVp57bXXVKXq3rZtG8ePH/eDVwItsdvtav9u+8q+oKWSYoEv8LJutUA7atWqxdSpU1XZqjhmKTAYBw8eVHPoKhfILPuiVoJvBPwE3KhRfwIf6devH61atXJrJwQffGzevFmNWbm7O2gj+GbAFpRidgIDceedd7q12bZtmydVSgUGQKXgf6noRV9X6asDy6kgSF8NderUoW/fvrRt2/avDC5JSUlERkb66JYAoHv37lit1ioDc0pLS9m/f7/qhT5BYLHb7WoF/31FL/oieBPwAdDCk0aRkZGMGDGCAQMGqJpyCrwnOjqaLl26sGHDhirtMjLKLeYKDMratWvV7MHL6CD4Z4Cb1BpLksSQIUN44IEHAlrsMdxo3LixEHwIsXz5cjVmu4Gsin7greAHAeqWgYEGDRrw6quvqq2CKdCQxET3T1uvv/46q1evJjExkXr16tGjRw/at28vtkMNxqlTp/jhhx/UmFaa6sgbwVuBl9Qad+jQgblz54rEigFCTTbbnJycSyK33njjDeLj4+nVqxf9+/fn2muvNVyhznDk7bffxulUVWO10iom3nyFjwNU5ZoaOHAgCxcuFGIPIN6uwOfm5rJs2TLGjh1LWloa27Zt09gzgSdkZWWxdOlSNaabgD8q+6Gngo8GVFVv7NSpEzNmzAjZc+zBgsqY6yrZvXs3o0aNYsKECWpDOgUaM2fOHDWVY6GKuzt4LviJgNsVtzp16vDqq6+qKqkk0Bct01qtXbuWv//973z/fYULwAKd2LVrF0uWLFFjmoWGgo9BRRlns9nM3LlzQ6JYRCiQnp6uaX+FhYVMmDCBd955R9N+BRVjt9t56qmn1GYpegWocs/OE8EPQQm0qZLU1FRatmzpQbcCvXA4HBw5ckTzfmVZZtasWTzxxBOa9y24lKeffprff/9djelZYK47I08EP8qdgc1m45577vGgS4GeHD16VHUWHG9YsmQJCxYs0K3/cOe9995j2bJlas1fRBF9lagVfAzQy53RqFGjRFCNgfjjj0oXazXj1VdfFQdwdGDDhg3MnDlTrXk68IIaQ7W15f4GfO2us2+//ZZGjRqpGVdgEAoLC8nIyOD06dNs3LiR7777joMHD3rUR3R0NEuXLqVx48Y6eRleHDp0iLS0NE+2VPsD31b0A2+LSc4EplRll5KSwjfffKPWQYGB+fnnn5k5cya7du1S3aZjx44sXrxYR6/Cg0OHDjF27FhPEpMsAYZX9kNvi0m6LY/Sq5fbGb8gSOjQoQOffPIJzz77LFarVVWbbdu2qcqlJ6icDRs2kJaW5onYjwDjPRlDreDdpkPt0aOHJ+MKDI4kSQwbNox3331XVXguwEsvvaR5+etw4b333mPcuHGeTOMdKHd2j4oGqhG8BUh2Z6SymqUgyOjQoQNvvPGGqjv9/v37+eqrr/zgVehgt9uZOnUqM2bM8PTL8l+AqoPxF6NG8DXU2NWuXa4UtSBEaNOmDU899ZQqW5XHNwUoEXS33HKLJ1tvF5gHeFVYQI3g3VZEiIuLEzHzIU5qaqqqwpebN28WBSvdkJWVxZNPPsnw4cPVBtVczBLA6zphagTvtgpEqFeGEShMnOg+IbHD4XCbcCNcOXXqFM899xy9e/fmk08+8aao5yrgVsDrhRI1gi90Z3D2rNsAH0EI0KpVK6655hq3dmvWrPGDN8GB3W5n5cqV3H333fTp04fFixerPfVWliUoZdrsvvij5jib22XDvLw87Ha7mNaHAb1792bjxo1V2ngxTQ0Z7HY7Bw8eZPPmzX9dKuvAVcU8lGm8z1sgagJvLEAxYK7Kbs2aNdSrp1mxGYFBOX78OH369KnSxmazqUqRHew4HA4KCgooKCggJyeHw4cPc/z4cS23Jh0oq/FeV/70NtLuAFBl3OR7771Hly5dvPVLEES0bdvW22mpQD1HUPbZPd56uxhvI+32uzNQmVxPEAIkJCQE2oVQZwlwFT6KvSLUCt5tULU4MRU+iByFupGOchDG4wg6tagV/Dp3BocOHdIl2YLAeOTm6vJZDGfOouSKbEUlp960Qq3gfwTcZlJQmXdLEORkZpYrSirwjizg/1CKsT4H6L4wolbw+YDbOfvixYs5c+aMbx4JDM3x48ex233aChYoqaQnoJxRmYaKTDVa4UmKq/fdGZSUlDBv3jwf3BEYnXXr1gXahWBERlkHew7l5Ok1wALcJJzUA08E/zlwzp3RsmXL+O2337x2SGBsRBSdKnJR7uILgDSU6sptUZ7T9c87VgVq9+EvMBXlW6pK6tSpw7Jly0Sq6hDj119/ZejQoWpMlwB7dHbHCJSgRKJeuDKAfYBhFjm8Dby5QDRKEI7bTJWdOnVi4cKFohhFCDFmzBj++9//ujOzAwmoCMkW6I+3gTcXKEApE+2WrVu38uijj4oFnhBh2bJlasQOSl1yIXaD4ukdHpTqsXtQWVBSVI8Nfnbt2sXIkSNxOBxqzEcAH+nskkAlvt7hQQnon6TW+OeffyYtLS2sT1AFMz///DPjxo1TK/ZdwCc6uyTwAW8ED0rB+WlqjY8dO0ZqaiqPPfaY2KcPIpYuXcro0aPJy8tT2+RRNDjCKdAPb6b0FzABy4GbPBkwMjKSESNGMGDAAFq1auVJU4Gf8CYvPfADcJ1OLgm8xNdV+rJURznR08IbZ+rUqUPfvn1p27YtiYmJJCYmkpSURGRkpDfdCbxAi8ozKPEZXYC92nso8AWtBQ9K5NCPKMEFgvBDBgYBKwLtiKA8WizalWU/0Bn4VYO+BMHH4wixBw+yLFd5eUAs8CXKN764wuN6HYGhKadnDQUPyoxhNoH/IIpL38sJPITA8Ogt+Av8HThO4D+Y4tL+Oo+HOzOCwOEvwYMyxX8BJa99oD+k4tLm+hy4HEHQ4E/BXyARmIVyZDDQH1hxeXdtBrqX/cMKjE8gBH8BG8rZ4C9QpoWB/hCLq+orA3gLJami271ZgTEpq2ct9uG9wYoSqNEVJWinBVAP5TEgFnUVcQTaUACcvOg6DHwD/BcRJhv0eBx4IxAIQgctAm8EAkGQIAQvEIQRQvACQRghBC8QhBFC8AJBGCEELxCEEULwAkEYIQQvEIQRQvACQRghBC8QhBFC8AJBGCEELxCEEULwAkEYIQQvEIQR/w/iRyoZYRaDOwAAAABJRU5ErkJggg\u003d\u003d' # noqa: E501 r = RawValue(data) diff --git a/tests/test_packages.py b/tests/test_packages.py index dfe545ca..b8434a7f 100644 --- a/tests/test_packages.py +++ b/tests/test_packages.py @@ -7,7 +7,7 @@ from HABApp import __version__ -def test_installation_check(): +def test_installation_check() -> None: re_name = re.compile(r'^([A-Za-z_-]{3,})') requirements = Path(__file__).parent.parent / 'requirements_setup.txt' assert requirements.is_file() @@ -24,6 +24,6 @@ def test_installation_check(): assert coded == found -def test_version(): +def test_version() -> None: check = re.compile(VERSION_PATTERN, re.VERBOSE | re.IGNORECASE) assert check.fullmatch(__version__) diff --git a/tests/test_parameters/test_base.py b/tests/test_parameters/test_base.py index 3701fcc0..b4b68fcf 100644 --- a/tests/test_parameters/test_base.py +++ b/tests/test_parameters/test_base.py @@ -10,7 +10,7 @@ params = params -def test_simple_key_creation(params: HABApp.parameters.parameters): +def test_simple_key_creation(params: HABApp.parameters.parameters) -> None: Parameter('file', 'key') assert params.get_parameter_file('file') == {'key': 'ToDo'} @@ -19,19 +19,19 @@ def test_simple_key_creation(params: HABApp.parameters.parameters): assert params.get_parameter_file('file') == {'key': 'ToDo', 'key2': 'ToDo'} -def test_structured_key_creation(params: HABApp.parameters.parameters): +def test_structured_key_creation(params: HABApp.parameters.parameters) -> None: Parameter('file', 'key1', 'key1') Parameter('file', 'key1', 'key2') assert params.get_parameter_file('file') == {'key1': {'key1': 'ToDo', 'key2': 'ToDo'}} -def test_structured_default_value(params: HABApp.parameters.parameters): +def test_structured_default_value(params: HABApp.parameters.parameters) -> None: Parameter('file', 'key1', 'key1', default_value=123) Parameter('file', 'key1', 'key2', default_value=[1, 2, 3]) assert params.get_parameter_file('file') == {'key1': {'key1': 123, 'key2': [1, 2, 3]}} -def test_lookup(params: HABApp.parameters.parameters): +def test_lookup(params: HABApp.parameters.parameters) -> None: data = {'key1': {'key2': 'value2'}} params.set_parameter_file('file1', data) p = Parameter('file1', 'key1', 'key2') diff --git a/tests/test_parameters/test_dict_parameter.py b/tests/test_parameters/test_dict_parameter.py index 48dc95b9..01db303b 100644 --- a/tests/test_parameters/test_dict_parameter.py +++ b/tests/test_parameters/test_dict_parameter.py @@ -11,7 +11,7 @@ params = params -def test_operators(params: HABApp.parameters.parameters): +def test_operators(params: HABApp.parameters.parameters) -> None: params.set_parameter_file('file', {'key': {1: 2, 3: 4}}) p = DictParameter('file', 'key') assert p == {1: 2, 3: 4} @@ -27,7 +27,7 @@ def test_operators(params: HABApp.parameters.parameters): assert [k for k in p] == [1, 3] -def test_funcs(params: HABApp.parameters.parameters): +def test_funcs(params: HABApp.parameters.parameters) -> None: params.set_parameter_file('file', {'key': {1: 2, 3: 4}}) p = DictParameter('file', 'key') @@ -41,7 +41,7 @@ def test_funcs(params: HABApp.parameters.parameters): assert p.get(5, 'asdf') == 'asdf' -def test_exception(params: HABApp.parameters.parameters): +def test_exception(params: HABApp.parameters.parameters) -> None: params.set_parameter_file('file', {'key': 'value'}) p = DictParameter('file', 'key') diff --git a/tests/test_parameters/test_parameter.py b/tests/test_parameters/test_parameter.py index 4ebc8f25..e54fe593 100644 --- a/tests/test_parameters/test_parameter.py +++ b/tests/test_parameters/test_parameter.py @@ -10,7 +10,7 @@ params = params -def test_int_operators(params: HABApp.parameters.parameters): +def test_int_operators(params: HABApp.parameters.parameters) -> None: params.set_parameter_file('file', {'key': 5}) p = Parameter('file', 'key') assert p == 5 @@ -28,7 +28,7 @@ def test_int_operators(params: HABApp.parameters.parameters): assert p > 4 -def test_float_operators(params: HABApp.parameters.parameters): +def test_float_operators(params: HABApp.parameters.parameters) -> None: params.set_parameter_file('file', {'key': 5.5}) p = Parameter('file', 'key') @@ -38,7 +38,7 @@ def test_float_operators(params: HABApp.parameters.parameters): assert p > 4 -def test_arithmetic(params: HABApp.parameters.parameters): +def test_arithmetic(params: HABApp.parameters.parameters) -> None: params.set_parameter_file('file', {'key': 1}) p = Parameter('file', 'key') diff --git a/tests/test_rule/test_hook.py b/tests/test_rule/test_hook.py index a9ac37b8..56612d0a 100644 --- a/tests/test_rule/test_hook.py +++ b/tests/test_rule/test_hook.py @@ -5,7 +5,7 @@ from HABApp.rule.rule_hook import HABAppRuleHook -def test_rule_hook_log(caplog): +def test_rule_hook_log(caplog) -> None: class MyRule: pass diff --git a/tests/test_rule/test_item_search.py b/tests/test_rule/test_item_search.py index 19d1d59a..6c06963f 100644 --- a/tests/test_rule/test_item_search.py +++ b/tests/test_rule/test_item_search.py @@ -7,7 +7,7 @@ from HABApp.openhab.items.base_item import MetaData -def test_search_type(ir: ItemRegistry): +def test_search_type(ir: ItemRegistry) -> None: item1 = BaseValueItem('item_1') item2 = Item('item_2') @@ -23,7 +23,7 @@ def test_search_type(ir: ItemRegistry): assert Rule.get_items(type=Item) == [item2] -def test_search_oh(ir: ItemRegistry): +def test_search_oh(ir: ItemRegistry) -> None: item1 = OpenhabItem('oh_item_1', tags=frozenset(['tag1', 'tag2', 'tag3']), groups=frozenset(['grp1', 'grp2']), metadata={'meta1': MetaData('meta_v1')}) item2 = SwitchItem('oh_item_2', tags=frozenset(['tag1', 'tag2', 'tag4']), @@ -56,12 +56,12 @@ def test_search_oh(ir: ItemRegistry): assert Rule.get_items(groups='grp1', metadata_value=r'meta_v\d') == [item1] -def test_classcheck(): +def test_classcheck() -> None: with pytest.raises(ValueError): Rule.get_items(Item, tags='asdf') -def test_search_name(ir: ItemRegistry): +def test_search_name(ir: ItemRegistry) -> None: item1 = BaseValueItem('item_1a') item2 = Item('item_2a') diff --git a/tests/test_rule/test_process.py b/tests/test_rule/test_process.py index fcff461c..dd20594b 100644 --- a/tests/test_rule/test_process.py +++ b/tests/test_rule/test_process.py @@ -23,7 +23,7 @@ class ProcRule(Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.cb = Mock() self.cb.__name__ = 'mock_callback' @@ -44,7 +44,7 @@ def rule(monkeypatch): @pytest.mark.no_internals() -async def test_run_func_arg_errors(rule): +async def test_run_func_arg_errors(rule) -> None: with pytest.raises(TypeError) as e: rule.execute_subprocess(rule.cb, sys.executable, 'asfd', 123) assert str(e.value) == 'args[2] is not of type str! "123" (int)' @@ -58,7 +58,7 @@ async def test_run_func_arg_errors(rule): @pytest.mark.parametrize('flag,result', [[True, FinishedProcessInfo(0, 'OK', '')], [False, 'OK']]) @pytest.mark.no_internals() -async def test_run_func(rule, flag, result): +async def test_run_func(rule, flag, result) -> None: await rule.execute_subprocess( rule.cb, sys.executable, '-c', 'import datetime; print("OK", end="")', capture_output=True, raw_info=flag @@ -69,7 +69,7 @@ async def test_run_func(rule, flag, result): @pytest.mark.parametrize('flag,result', [[True, FinishedProcessInfo(0, None, None)], [False, '']]) @pytest.mark.no_internals() -async def test_run_func_no_cap(rule, flag: bool, result): +async def test_run_func_no_cap(rule, flag: bool, result) -> None: await rule.execute_subprocess( rule.cb, sys.executable, '-c', 'import datetime; print("OK", end="")', capture_output=False, raw_info=flag ) @@ -79,7 +79,7 @@ async def test_run_func_no_cap(rule, flag: bool, result): @pytest.mark.parametrize('flag,result', [[True, FinishedProcessInfo(0, None, None)], [False, '']]) @pytest.mark.no_internals() -async def test_run_func_cancel(rule, flag, result, test_logs: LogCollector): +async def test_run_func_cancel(rule, flag, result, test_logs: LogCollector) -> None: task = rule.execute_subprocess( rule.cb, sys.executable, '-c', 'import time; time.sleep(5)', capture_output=False, raw_info=flag @@ -99,7 +99,7 @@ async def test_run_func_cancel(rule, flag, result, test_logs: LogCollector): @pytest.mark.parametrize('flag', [True, False]) @pytest.mark.no_internals() -async def test_invalid_program(rule, test_logs, flag): +async def test_invalid_program(rule, test_logs, flag) -> None: parent_dir = Path(__file__).parent await rule.execute_subprocess(rule.cb, 'ProgramThatDoesNotExist', capture_output=True, raw_info=flag) @@ -115,7 +115,7 @@ async def test_invalid_program(rule, test_logs, flag): @pytest.mark.parametrize('raw_info', [True, False]) @pytest.mark.no_internals() -async def test_exec_python_file(rule, caplog, raw_info): +async def test_exec_python_file(rule, caplog, raw_info) -> None: parent_dir = Path(__file__).parent await rule.execute_python(rule.cb, parent_dir / '__exec_python_file.py', capture_output=True, raw_info=raw_info) @@ -134,7 +134,7 @@ async def test_exec_python_file(rule, caplog, raw_info): @pytest.mark.no_internals() -async def test_exec_python_file_relative(rule): +async def test_exec_python_file_relative(rule) -> None: parent_dir = Path(__file__).parent await rule.execute_python(rule.cb, '__exec_python_file.py', capture_output=True) @@ -145,7 +145,7 @@ async def test_exec_python_file_relative(rule): @pytest.mark.no_internals() -async def test_exec_python_file_error_stderr(rule, test_logs: LogCollector): +async def test_exec_python_file_error_stderr(rule, test_logs: LogCollector) -> None: folder = Path(__file__).parent file = folder / '__exec_python_file.py' @@ -162,7 +162,7 @@ async def test_exec_python_file_error_stderr(rule, test_logs: LogCollector): @pytest.mark.no_internals() -async def test_exec_python_file_error_stdout(rule, test_logs: LogCollector): +async def test_exec_python_file_error_stdout(rule, test_logs: LogCollector) -> None: folder = Path(__file__).parent file = folder / '__exec_python_file.py' await rule.execute_python(rule.cb, file, 'exit_3', capture_output=True, additional_python_path=[folder]) @@ -178,7 +178,7 @@ async def test_exec_python_file_error_stdout(rule, test_logs: LogCollector): @pytest.mark.parametrize('raw_info, result', [[True, FinishedProcessInfo(0, 'module ok', '')], [False, 'module ok']]) @pytest.mark.no_internals() -async def test_exec_python_module(rule, raw_info, result): +async def test_exec_python_module(rule, raw_info, result) -> None: folder = Path(__file__).parent await rule.execute_python( rule.cb, '__exec_python_module', capture_output=True, additional_python_path=[folder], raw_info=raw_info) @@ -187,7 +187,7 @@ async def test_exec_python_module(rule, raw_info, result): @pytest.mark.no_internals() -def test_param_pythonpath(monkeypatch): +def test_param_pythonpath(monkeypatch) -> None: monkeypatch.setattr(HABApp.CONFIG, '_file_path', Path(__file__)) folder = str(Path(__file__).parent) diff --git a/tests/test_rule/test_rule_factory.py b/tests/test_rule/test_rule_factory.py index fd1301fc..1a258447 100644 --- a/tests/test_rule/test_rule_factory.py +++ b/tests/test_rule/test_rule_factory.py @@ -4,7 +4,7 @@ from tests import SimpleRuleRunner -def test_rule_no_create(): +def test_rule_no_create() -> None: class MyRule(Rule): pass @@ -12,7 +12,7 @@ class MyRule(Rule): @pytest.mark.no_internals() -def test_rule_create(): +def test_rule_create() -> None: class MyRule(Rule): pass diff --git a/tests/test_rule/test_rule_funcs.py b/tests/test_rule/test_rule_funcs.py index 56ffdb90..c7a8faae 100644 --- a/tests/test_rule/test_rule_funcs.py +++ b/tests/test_rule/test_rule_funcs.py @@ -9,7 +9,7 @@ @pytest.mark.no_internals() -def test_unload_function(): +def test_unload_function() -> None: with SimpleRuleRunner(): r = Rule() @@ -21,7 +21,7 @@ def test_unload_function(): @pytest.mark.ignore_log_errors() @pytest.mark.no_internals() -def test_unload_function_exception(eb: TestEventBus): +def test_unload_function_exception(eb: TestEventBus) -> None: eb.allow_errors = True with SimpleRuleRunner(): @@ -34,10 +34,10 @@ def test_unload_function_exception(eb: TestEventBus): @pytest.mark.no_internals() -def test_repr(): +def test_repr() -> None: class Abc(Rule): - def __init__(self): + def __init__(self) -> None: super().__init__() self.rule_name = 'Abc' diff --git a/tests/test_utils/test_fade.py b/tests/test_utils/test_fade.py index 9d83f717..d879666e 100644 --- a/tests/test_utils/test_fade.py +++ b/tests/test_utils/test_fade.py @@ -1,7 +1,7 @@ from HABApp.util.fade.fade import Fade -def test_setup(): +def test_setup() -> None: f = Fade().setup(0, 10, 10) assert f._fade_factor == 1 assert f._step_duration == 1 @@ -31,7 +31,7 @@ def test_setup(): assert f._step_duration == 2 -def test_values(): +def test_values() -> None: f = Fade().setup(0, 10, 5, now=10) assert f.get_value(11) == 2 assert f.get_value(12) == 4 diff --git a/tests/test_utils/test_functions.py b/tests/test_utils/test_functions.py index 0a01d92b..1b771199 100644 --- a/tests/test_utils/test_functions.py +++ b/tests/test_utils/test_functions.py @@ -3,14 +3,14 @@ from HABApp.util.functions import hsb_to_rgb, max, min, rgb_to_hsb -def test_none_remove(): +def test_none_remove() -> None: for func in (max, min): assert func(1, None) == 1 assert func(None, None, default=2) == 2 assert func([], default='asdf') == 'asdf' -def test_min(): +def test_min() -> None: assert min(1, None) == 1 assert min(2, 3, None) == 2 @@ -18,7 +18,7 @@ def test_min(): assert min([2, 3, None]) == 2 -def test_max(): +def test_max() -> None: assert max(1, None) == 1 assert max(2, 3, None) == 3 @@ -32,7 +32,7 @@ def test_max(): ((128, 138, 33), ( 65.71, 76.09, 54.12)), (( 0, 0, 0), ( 0, 0, 0)), ]) -def test_rgb_to_hsv(rgb, hsv): +def test_rgb_to_hsv(rgb, hsv) -> None: assert hsv == rgb_to_hsb(*rgb) @@ -43,5 +43,5 @@ def test_rgb_to_hsv(rgb, hsv): (( 0, 100, 100), (255, 0, 0)), (( 0, 0, 0), ( 0, 0, 0)), ]) -def test_hsv_to_rgb(hsv, rgb): +def test_hsv_to_rgb(hsv, rgb) -> None: assert rgb == hsb_to_rgb(*hsv) diff --git a/tests/test_utils/test_listener_groups.py b/tests/test_utils/test_listener_groups.py index 5b52f848..d70ec3c6 100644 --- a/tests/test_utils/test_listener_groups.py +++ b/tests/test_utils/test_listener_groups.py @@ -11,7 +11,7 @@ class PatchedBaseItem(BaseItem): NAME = 'PatchedBaseItem' - def __init__(self): + def __init__(self) -> None: super().__init__(PatchedBaseItem.NAME) listener = Mock() @@ -22,7 +22,7 @@ def __init__(self): watch_change = Mock(return_value=no_x_watch) watch_update = Mock(return_value=no_x_watch) - def reset(self): + def reset(self) -> None: self.listener.reset_mock() self.listen_event.reset_mock() self.no_x_watch.reset_mock() @@ -36,7 +36,7 @@ def patched_item() -> BaseItem: return item -def test_not_found(): +def test_not_found() -> None: msg = 'ListenerCreator for "asdf" not found!' g = EventListenerGroup() @@ -59,13 +59,13 @@ def test_not_found(): @pytest.mark.parametrize('func', ('add_listener', 'add_no_update_watcher', 'add_no_change_watcher')) -def test_activate_deactivate(func): +def test_activate_deactivate(func) -> None: g = EventListenerGroup() item = patched_item() cb = object() p1 = object() - def assert_called_once(): + def assert_called_once() -> None: if func == 'add_listener': item.listen_event.assert_called_once_with(cb, p1) else: @@ -96,7 +96,7 @@ def assert_called_once(): item.listener.cancel.assert_called_once_with() -def test_activate(): +def test_activate() -> None: g = EventListenerGroup() g._items['a'] = m = Mock() @@ -117,7 +117,7 @@ def test_activate(): assert len(m.listen.mock_calls) == 2 -def test_listen_add(monkeypatch): +def test_listen_add(monkeypatch) -> None: m = Mock() monkeypatch.setattr( HABApp.util.listener_groups.listener_groups, EventListenerCreator.__name__, Mock(return_value=m)) diff --git a/tests/test_utils/test_multivalue.py b/tests/test_utils/test_multivalue.py index 74bca650..ebf04b0e 100644 --- a/tests/test_utils/test_multivalue.py +++ b/tests/test_utils/test_multivalue.py @@ -1,4 +1,3 @@ -from typing import Tuple from unittest.mock import Mock import pytest @@ -7,15 +6,8 @@ from HABApp.util.multimode import BaseMode, MultiModeItem, ValueMode from tests.helpers.parent_rule import DummyRule -from ..test_core import ItemTests - -class TestMultiModeItem(ItemTests): - CLS = MultiModeItem - TEST_VALUES = [0, 'str', (1, 2, 3)] - - -def test_diff_prio(parent_rule: DummyRule): +def test_diff_prio(parent_rule: DummyRule) -> None: p = MultiModeItem('TestItem') p1 = ValueMode('modea', '1234') p2 = ValueMode('modeb', '4567') @@ -35,7 +27,7 @@ def test_diff_prio(parent_rule: DummyRule): assert p.value == 8888 -def get_value_mode(enabled: bool, enable_on_value: bool, current_value=0) -> Tuple[Mock, ValueMode]: +def get_value_mode(enabled: bool, enable_on_value: bool, current_value=0) -> tuple[Mock, ValueMode]: parent = Mock() parent.calculate_value = Mock() @@ -44,28 +36,28 @@ def get_value_mode(enabled: bool, enable_on_value: bool, current_value=0) -> Tup return parent.calculate_value, mode -def test_only_on_change_1(parent_rule: DummyRule): +def test_only_on_change_1(parent_rule: DummyRule) -> None: calculate_value, mode = get_value_mode(enabled=False, enable_on_value=False) assert not mode.set_value(0, only_on_change=True) calculate_value.assert_not_called() -def test_only_on_change_2(parent_rule: DummyRule): +def test_only_on_change_2(parent_rule: DummyRule) -> None: calculate_value, mode = get_value_mode(enabled=True, enable_on_value=False) assert not mode.set_value(0, only_on_change=True) calculate_value.assert_not_called() -def test_only_on_change_3(parent_rule: DummyRule): +def test_only_on_change_3(parent_rule: DummyRule) -> None: calculate_value, mode = get_value_mode(enabled=False, enable_on_value=True) assert mode.set_value(0, only_on_change=True) calculate_value.assert_called_once() -def test_only_on_change_4(parent_rule: DummyRule): +def test_only_on_change_4(parent_rule: DummyRule) -> None: calculate_value, mode = get_value_mode(enabled=True, enable_on_value=True) assert not mode.set_value(0, only_on_change=True) @@ -74,7 +66,7 @@ def test_only_on_change_4(parent_rule: DummyRule): @pytest.mark.parametrize('enabled', (True, False)) @pytest.mark.parametrize('enable_on_value', (True, False)) -def test_only_on_change_diff_value(parent_rule: DummyRule, enabled, enable_on_value): +def test_only_on_change_diff_value(parent_rule: DummyRule, enabled, enable_on_value) -> None: calculate_value, mode = get_value_mode(enabled=enabled, enable_on_value=enable_on_value) @@ -82,7 +74,7 @@ def test_only_on_change_diff_value(parent_rule: DummyRule, enabled, enable_on_va calculate_value.assert_called_once() -def test_calculate_lower_priority_value(parent_rule: DummyRule): +def test_calculate_lower_priority_value(parent_rule: DummyRule) -> None: p = MultiModeItem('TestItem', default_value=99) m1 = ValueMode('modea', '1234') m2 = ValueMode('modeb', '4567') @@ -96,7 +88,7 @@ def test_calculate_lower_priority_value(parent_rule: DummyRule): assert m2.calculate_lower_priority_value() == 'asdf' -def test_auto_disable_1(parent_rule: DummyRule): +def test_auto_disable_1(parent_rule: DummyRule) -> None: p = MultiModeItem('TestItem') m1 = ValueMode('modea', 50) m2 = ValueMode('modeb', 60, auto_disable_func= lambda lower, o: lower > o) @@ -113,7 +105,7 @@ def test_auto_disable_1(parent_rule: DummyRule): assert p.value == 59 -def test_auto_disable_func(parent_rule: DummyRule): +def test_auto_disable_func(parent_rule: DummyRule) -> None: p = MultiModeItem('TestItem') m1 = ValueMode('modea', 50) m2 = ValueMode('modeb', 60, auto_disable_func=lambda low, s: low == 40) @@ -133,7 +125,7 @@ def test_auto_disable_func(parent_rule: DummyRule): assert m2.enabled is False -def test_unknown(parent_rule: DummyRule): +def test_unknown(parent_rule: DummyRule) -> None: p = MultiModeItem('asdf') with pytest.raises(KeyError): p.get_mode('asdf') @@ -143,7 +135,7 @@ def test_unknown(parent_rule: DummyRule): p.get_mode('asdf') -def test_remove(parent_rule: DummyRule): +def test_remove(parent_rule: DummyRule) -> None: p = MultiModeItem('asdf') m1 = BaseMode('m1') m2 = BaseMode('m2') @@ -156,7 +148,7 @@ def test_remove(parent_rule: DummyRule): assert p.all_modes() == [(1, m2)] -def test_overwrite(parent_rule: DummyRule): +def test_overwrite(parent_rule: DummyRule) -> None: p = MultiModeItem('asdf') m1 = BaseMode('m1') m2 = BaseMode('m1') @@ -167,7 +159,7 @@ def test_overwrite(parent_rule: DummyRule): assert p.all_modes() == [(1, m2), (5, m3)] -def test_order(parent_rule: DummyRule): +def test_order(parent_rule: DummyRule) -> None: p = MultiModeItem('asdf') m1 = BaseMode('m1') m2 = BaseMode('m2') @@ -180,7 +172,7 @@ def test_order(parent_rule: DummyRule): assert p.all_modes() == [(1, m2), (5, m3), (99, m1)] -def test_disable_no_default(parent_rule: DummyRule): +def test_disable_no_default(parent_rule: DummyRule) -> None: # No default_value is set -> we don't send anything if all modes are disabled p1 = ValueMode('modea', '1234') @@ -192,7 +184,7 @@ def test_disable_no_default(parent_rule: DummyRule): assert p.value == '1234' -def test_disable_with_default(parent_rule: DummyRule): +def test_disable_with_default(parent_rule: DummyRule) -> None: # We have default_value set -> send it when all modes are disabled a1 = ValueMode('modea', '1234') diff --git a/tests/test_utils/test_rate_limiter.py b/tests/test_utils/test_rate_limiter.py index 9c2c52bb..27074ccd 100644 --- a/tests/test_utils/test_rate_limiter.py +++ b/tests/test_utils/test_rate_limiter.py @@ -33,7 +33,7 @@ def time(monkeypatch) -> MockedMonotonic: ('day', 24 * 3600), ('month', 30 * 24 * 3600), ('year', 365 * 24 * 3600) ] ) -def test_parse(unit: str, factor: int): +def test_parse(unit: str, factor: int) -> None: assert parse_limit(f' 1 per {unit} ') == (1, factor) assert parse_limit(f' 1 / {unit} ') == (1, factor) assert parse_limit(f'3 per {unit}') == (3, factor) @@ -48,7 +48,7 @@ def test_parse(unit: str, factor: int): assert str(e.value) == 'Invalid limit string: "asdf"' -def test_parse_regex_all_units(): +def test_parse_regex_all_units() -> None: m = re.search(r'\(([^)]+)\)s\?', LIMIT_REGEX.pattern) values = m.group(1) @@ -57,7 +57,7 @@ def test_parse_regex_all_units(): parse_limit(f'1 in 3 {unit}s') -def test_fixed_window(time): +def test_fixed_window(time) -> None: limit = FixedWindowElasticExpiryLimit(5, 3) assert str(limit) == '' @@ -84,7 +84,7 @@ def test_fixed_window(time): assert str(limit) == '' -def test_leaky_bucket(time): +def test_leaky_bucket(time) -> None: limit = LeakyBucketLimit(4, 2) assert str(limit) == '' for _ in range(10): @@ -129,7 +129,7 @@ def test_leaky_bucket(time): assert limit.hits == 0 -def test_window_test_allow(time): +def test_window_test_allow(time) -> None: limit = FixedWindowElasticExpiryLimit(5, 3) limit.hits = 5 @@ -142,7 +142,7 @@ def test_window_test_allow(time): assert not limit.hits -def test_limiter_add(time): +def test_limiter_add(time) -> None: limiter = Limiter('test') limiter.add_limit(3, 5).add_limit(3, 5).parse_limits('3 in 5s') assert len(limiter._limits) == 1 @@ -164,7 +164,7 @@ def test_limiter_add(time): assert str(e.value) == 'Parameter hits must be <= parameter allowed! 5 <= 3!' -def test_fixed_window_info(time): +def test_fixed_window_info(time) -> None: limit = FixedWindowElasticExpiryLimit(5, 3) Info = FixedWindowElasticExpiryLimitInfo @@ -187,14 +187,14 @@ def test_fixed_window_info(time): assert limit.info() == Info(hits=0, skips=0, limit=5, time_remaining=3) -def test_leaky_bucket_info(time): +def test_leaky_bucket_info(time) -> None: limit = LeakyBucketLimit(2, 2) Info = LeakyBucketLimitInfo assert limit.info() == Info(hits=0, skips=0, limit=2, time_remaining=1) -def test_registry(monkeypatch): +def test_registry(monkeypatch) -> None: monkeypatch.setattr(registry_module, '_LIMITERS', {}) obj = registry_module.RateLimiter('Test') @@ -202,7 +202,7 @@ def test_registry(monkeypatch): assert obj is registry_module.RateLimiter('test') -def test_limiter(time): +def test_limiter(time) -> None: limiter = Limiter('Test') assert limiter.__repr__() == '' diff --git a/tests/test_utils/test_statistics.py b/tests/test_utils/test_statistics.py index 63893dc5..7c96dbaf 100644 --- a/tests/test_utils/test_statistics.py +++ b/tests/test_utils/test_statistics.py @@ -5,13 +5,13 @@ class TestCases(unittest.TestCase): - def test_constructor(self): + def test_constructor(self) -> None: with self.assertRaises(ValueError): Statistics() Statistics(max_age=20) Statistics(max_samples=20) - def test_basic(self): + def test_basic(self) -> None: stat = Statistics(max_age=20) stat.add_value(1) stat.add_value(2) diff --git a/tests/test_utils/test_threshold.py b/tests/test_utils/test_threshold.py index d5ea678a..cf28979d 100644 --- a/tests/test_utils/test_threshold.py +++ b/tests/test_utils/test_threshold.py @@ -5,13 +5,13 @@ class TestCases(unittest.TestCase): - def test_constructor(self): + def test_constructor(self) -> None: t = Threshold(10, 20) self.assertEqual(t.current_threshold, 20) t = Threshold(20, 10) self.assertEqual(t.current_threshold, 20) - def test_a(self): + def test_a(self) -> None: t = Threshold(10, 20) self.assertFalse( t < 19) @@ -24,7 +24,7 @@ def test_a(self): self.assertFalse(t < 9) self.assertFalse(t < 19) - def test_b(self): + def test_b(self) -> None: t = Threshold(10, 20) self.assertEqual(t.current_threshold, 20) diff --git a/tools/prettify_json.py b/tools/prettify_json.py index c65cbd3c..58b729a5 100644 --- a/tools/prettify_json.py +++ b/tools/prettify_json.py @@ -9,7 +9,7 @@ to_prettify = '' -def load_str(in_str): +def load_str(in_str: str) -> str: if not isinstance(in_str, str): return in_str @@ -18,8 +18,8 @@ def load_str(in_str): return in_str -def load_obj(obj): - new_obj = load_str(obj) +def load_obj(obj_str: str) -> object: + new_obj = load_str(obj_str) if isinstance(new_obj, list): for i, obj in enumerate(new_obj): new_obj[i] = load_obj(obj) diff --git a/tox.ini b/tox.ini index 5f1003f3..721274ed 100644 --- a/tox.ini +++ b/tox.ini @@ -1,21 +1,20 @@ # content of: tox.ini , put in same dir as setup.py [tox] envlist = - py38 - py39 py310 py311 py312 + py313 docs + slotscheck [gh-actions] python = - 3.8: py38 - 3.9: py39 - 3.10: py310, docs + 3.10: py310 3.11: py311 - 3.12: py312 + 3.12: py312, slotscheck, docs + 3.13: py313 [testenv] @@ -26,8 +25,13 @@ commands = python -m pytest -# Environment variable CI from github actions -pass_env = CI +[testenv:slotscheck] +deps = + -r{toxinidir}/requirements.txt + slotscheck +change_dir = {toxinidir}/src +commands = + python -m slotscheck HABApp --verbose [testenv:docs] @@ -40,10 +44,15 @@ deps = commands = sphinx-build -d "{toxworkdir}/docs_doctree" docs "{toxworkdir}/docs_out" --color -bhtml {posargs} +# Environment variable CI from github actions +pass_env = CI [pytest] +# https://pytest-asyncio.readthedocs.io/en/latest/reference/configuration.html asyncio_mode = auto +asyncio_default_fixture_loop_scope = function + norecursedirs = run docs