diff --git a/ovos_workshop/decorators/__init__.py b/ovos_workshop/decorators/__init__.py index e135dd54..b9a9b9a7 100644 --- a/ovos_workshop/decorators/__init__.py +++ b/ovos_workshop/decorators/__init__.py @@ -1,24 +1,15 @@ +from ovos_utils.log import log_deprecation from ovos_workshop.decorators.killable import killable_intent, killable_event from ovos_workshop.decorators.layers import enables_layer, \ disables_layer, layer_intent, removes_layer, resets_layers, replaces_layer -from ovos_workshop.decorators.converse import converse_handler -from ovos_workshop.decorators.fallback_handler import fallback_handler -from ovos_utils import classproperty +from ovos_workshop.decorators.ocp import ocp_play, ocp_pause, ocp_resume, \ + ocp_search, ocp_previous, ocp_featured_media from functools import wraps -try: - from ovos_workshop.decorators.ocp import ocp_next, ocp_play, ocp_pause, ocp_resume, ocp_search, ocp_previous, ocp_featured_media -except ImportError: - pass # these imports are only available if extra requirements are installed -""" -Decorators for use with MycroftSkill methods -Helper decorators for handling context from skills. -""" - - -def adds_context(context, words=''): - """Decorator adding context to the Adapt context manager. +def adds_context(context: str, words: str = ''): + """ + Decorator to add context to the Adapt context manager. Args: context (str): context Keyword to insert @@ -31,14 +22,13 @@ def func_wrapper(*args, **kwargs): ret = func(*args, **kwargs) args[0].set_context(context, words) return ret - return func_wrapper - return context_add_decorator -def removes_context(context): - """Decorator removing context from the Adapt context manager. +def removes_context(context: str): + """ + Decorator to remove context from the Adapt context manager. Args: context (str): Context keyword to remove @@ -50,14 +40,15 @@ def func_wrapper(*args, **kwargs): ret = func(*args, **kwargs) args[0].remove_context(context) return ret - return func_wrapper - return context_removes_decorator -def intent_handler(intent_parser): - """Decorator for adding a method as an intent handler.""" +def intent_handler(intent_parser: object): + """ + Decorator for adding a method as an intent handler. + @param intent_parser: string intent name or adapt.IntentBuilder object + """ def real_decorator(func): # Store the intent_parser inside the function @@ -70,12 +61,10 @@ def real_decorator(func): return real_decorator -def intent_file_handler(intent_file): - """Decorator for adding a method as an intent file handler. - - This decorator is deprecated, use intent_handler for the same effect. +def intent_file_handler(intent_file: str): + """ + Deprecated decorator for adding a method as an intent file handler. """ - def real_decorator(func): # Store the intent_file inside the function # This will be used later to call register_intent_file @@ -83,14 +72,15 @@ def real_decorator(func): func.intent_files = [] func.intent_files.append(intent_file) return func - + log_deprecation(f"Use `@intent_handler({intent_file})`", "0.1.0") return real_decorator -def resting_screen_handler(name): - """Decorator for adding a method as an resting screen handler. - - If selected will be shown on screen when device enters idle mode. +def resting_screen_handler(name: str): + """ + Decorator for adding a method as a resting screen handler to optionally + be shown on screen when device enters idle mode. + @param name: Name of the restring screen to register """ def real_decorator(func): @@ -103,13 +93,38 @@ def real_decorator(func): return real_decorator -def skill_api_method(func): - """Decorator for adding a method to the skill's public api. - - Methods with this decorator will be registered on the message bus - and an api object can be created for interaction with the skill. +def skill_api_method(func: callable): + """ + Decorator for adding a method to the skill's public api. Methods with this + decorator will be registered on the messagebus and an api object can be + created for interaction with the skill. + @param func: API method to expose """ # tag the method by adding an api_method member to it if not hasattr(func, 'api_method') and hasattr(func, '__name__'): func.api_method = True return func + + +def converse_handler(func): + """ + Decorator for aliasing a method as the converse method + """ + if not hasattr(func, 'converse'): + func.converse = True + return func + + +def fallback_handler(priority: int = 50): + """ + Decorator for adding a fallback intent handler. + + @param priority: Fallback priority (0-100) with lower values having higher + priority + """ + def real_decorator(func): + if not hasattr(func, 'fallback_priority'): + func.fallback_priority = priority + return func + + return real_decorator diff --git a/ovos_workshop/decorators/converse.py b/ovos_workshop/decorators/converse.py index 126761d7..f4e5fcc2 100644 --- a/ovos_workshop/decorators/converse.py +++ b/ovos_workshop/decorators/converse.py @@ -1,11 +1,11 @@ +from ovos_utils.log import log_deprecation -def converse_handler(): - """Decorator for aliasing a method as the converse method""" - - def real_decorator(func): - if not hasattr(func, 'converse'): - func.converse = True - return func - - return real_decorator +def converse_handler(func): + """ + Decorator for aliasing a method as the converse method + """ + log_deprecation("Import from `ovos_workshop.decorators`", "0.1.0") + if not hasattr(func, 'converse'): + func.converse = True + return func diff --git a/ovos_workshop/decorators/fallback_handler.py b/ovos_workshop/decorators/fallback_handler.py index 56b79c49..76ff7861 100644 --- a/ovos_workshop/decorators/fallback_handler.py +++ b/ovos_workshop/decorators/fallback_handler.py @@ -1,5 +1,9 @@ +from ovos_utils.log import log_deprecation + def fallback_handler(priority=50): + log_deprecation("Import from `ovos_workshop.decorators`", "0.1.0") + def real_decorator(func): if not hasattr(func, 'fallback_priority'): func.fallback_priority = priority diff --git a/ovos_workshop/decorators/killable.py b/ovos_workshop/decorators/killable.py index 65e2f651..cb65aa5c 100644 --- a/ovos_workshop/decorators/killable.py +++ b/ovos_workshop/decorators/killable.py @@ -1,3 +1,5 @@ +from typing import Callable, Optional, Type + import time from ovos_utils import create_killable_daemon from ovos_utils.messagebus import Message @@ -18,16 +20,36 @@ class AbortQuestion(AbortEvent): """ gracefully abort get_response queries """ -def killable_intent(msg="mycroft.skills.abort_execution", - callback=None, react_to_stop=True, call_stop=True, - stop_tts=True): +def killable_intent(msg: str = "mycroft.skills.abort_execution", + callback: Optional[callable] = None, + react_to_stop: bool = True, + call_stop: bool = True, stop_tts: bool = True) -> callable: + """ + Decorator to mark an intent that can be terminated during execution. + @param msg: Message name to terminate on + @param callback: Optional function or method to call on termination + @param react_to_stop: If true, also terminate on `stop` Messages + @param call_stop: If true, also call `Class.stop` method + @param stop_tts: If true, emit message to stop TTS audio playback + """ return killable_event(msg, AbortIntent, callback, react_to_stop, call_stop, stop_tts) -def killable_event(msg="mycroft.skills.abort_execution", exc=AbortEvent, - callback=None, react_to_stop=False, call_stop=False, - stop_tts=False): +def killable_event(msg: str = "mycroft.skills.abort_execution", + exc: Type[Exception] = AbortEvent, + callback: Optional[callable] = None, + react_to_stop: bool = False, call_stop: bool = False, + stop_tts: bool = False): + """ + Decorator to mark a method that can be terminated during execution. + @param msg: Message name to terminate on + @param exc: Exception to raise in killed thread + @param callback: Optional function or method to call on termination + @param react_to_stop: If true, also terminate on `stop` Messages + @param call_stop: If true, also call `Class.stop` method + @param stop_tts: If true, emit message to stop TTS audio playback + """ # Begin wrapper def create_killable(func): @@ -54,7 +76,7 @@ def abort(_): try: while t.is_alive(): t.raise_exc(exc) - time.sleep(0.1) + t.join(1) except threading.ThreadError: pass # already killed except AssertionError: @@ -79,4 +101,3 @@ def abort(_): return call_function return create_killable - diff --git a/ovos_workshop/decorators/layers.py b/ovos_workshop/decorators/layers.py index e39ca520..ebcc64a5 100644 --- a/ovos_workshop/decorators/layers.py +++ b/ovos_workshop/decorators/layers.py @@ -1,9 +1,16 @@ import inspect from functools import wraps +from typing import Optional, List + from ovos_utils.log import LOG -def dig_for_skill(max_records: int = 10): +def dig_for_skill(max_records: int = 10) -> Optional[object]: + """ + Dig through the call stack to locate a Skill object + @param max_records: maximum number of records in the stack to check + @return: Skill or AbstractApplication instance if found + """ from ovos_workshop.app import OVOSAbstractApplication from ovos_workshop.skills import MycroftSkill stack = inspect.stack()[1:] # First frame will be this function call @@ -23,13 +30,17 @@ def dig_for_skill(max_records: int = 10): return None -def enables_layer(layer_name): +def enables_layer(layer_name: str): + """ + Decorator to enable an intent layer when a method is called + @param layer_name: name of intent layer to enable + """ def layer_handler(func): @wraps(func) def call_function(*args, **kwargs): skill = dig_for_skill() skill.intent_layers = skill.intent_layers or \ - IntentLayers().bind(skill) + IntentLayers().bind(skill) func(*args, **kwargs) skill.intent_layers.activate_layer(layer_name) @@ -38,13 +49,17 @@ def call_function(*args, **kwargs): return layer_handler -def disables_layer(layer_name): +def disables_layer(layer_name: str): + """ + Decorator to disable an intent layer when a method is called + @param layer_name: name of intent layer to disable + """ def layer_handler(func): @wraps(func) def call_function(*args, **kwargs): skill = dig_for_skill() skill.intent_layers = skill.intent_layers or \ - IntentLayers().bind(skill) + IntentLayers().bind(skill) func(*args, **kwargs) skill.intent_layers.deactivate_layer(layer_name) @@ -53,13 +68,18 @@ def call_function(*args, **kwargs): return layer_handler -def replaces_layer(layer_name, intent_list): +def replaces_layer(layer_name: str, intent_list: Optional[List[str]]): + """ + Replaces intents at the specified layer + @param layer_name: name of intent layer to replace + @param intent_list: list of new intents for the specified layer + """ def layer_handler(func): @wraps(func) def call_function(*args, **kwargs): skill = dig_for_skill() skill.intent_layers = skill.intent_layers or \ - IntentLayers().bind(skill) + IntentLayers().bind(skill) func(*args, **kwargs) skill.intent_layers.replace_layer(layer_name, intent_list) diff --git a/ovos_workshop/decorators/ocp.py b/ovos_workshop/decorators/ocp.py index 532fad71..164563a0 100644 --- a/ovos_workshop/decorators/ocp.py +++ b/ovos_workshop/decorators/ocp.py @@ -30,7 +30,9 @@ def real_decorator(func): def ocp_play(): - """Decorator for adding a method as an common play search handler.""" + """ + Decorator for adding a method to handle media playback. + """ def real_decorator(func): # Store the flag inside the function @@ -44,7 +46,9 @@ def real_decorator(func): def ocp_previous(): - """Decorator for adding a method as an common play prev handler.""" + """ + Decorator for adding a method to handle requests to skip backward. + """ def real_decorator(func): # Store the flag inside the function @@ -58,7 +62,9 @@ def real_decorator(func): def ocp_next(): - """Decorator for adding a method as an common play next handler.""" + """ + Decorator for adding a method to handle requests to skip forward. + """ def real_decorator(func): # Store the flag inside the function @@ -72,7 +78,9 @@ def real_decorator(func): def ocp_pause(): - """Decorator for adding a method as an common play pause handler.""" + """ + Decorator for adding a method to handle requests to pause playback. + """ def real_decorator(func): # Store the flag inside the function @@ -86,7 +94,9 @@ def real_decorator(func): def ocp_resume(): - """Decorator for adding a method as an common play resume handler.""" + """ + Decorator for adding a method to handle requests to resume playback. + """ def real_decorator(func): # Store the flag inside the function @@ -100,7 +110,9 @@ def real_decorator(func): def ocp_featured_media(): - """Decorator for adding a method as an common play search handler.""" + """ + Decorator for adding a method to handle requests to provide featured media. + """ def real_decorator(func): # Store the flag inside the function @@ -114,8 +126,9 @@ def real_decorator(func): try: - from ovos_plugin_common_play.ocp.status import MediaType, PlayerState, MediaState, MatchConfidence, \ - PlaybackType, PlaybackMode, LoopState, TrackState + from ovos_plugin_common_play.ocp.status import MediaType, PlayerState, \ + MediaState, MatchConfidence, PlaybackType, PlaybackMode, LoopState, \ + TrackState except ImportError: # TODO - manually keep these in sync as needed diff --git a/ovos_workshop/skills/base.py b/ovos_workshop/skills/base.py index bd769880..7dae3460 100644 --- a/ovos_workshop/skills/base.py +++ b/ovos_workshop/skills/base.py @@ -49,8 +49,8 @@ from ovos_utils.process_utils import RuntimeRequirements from ovos_utils.skills import get_non_properties from ovos_utils.sound import play_acknowledge_sound +from ovos_utils import classproperty -from ovos_workshop.decorators import classproperty from ovos_workshop.decorators.killable import AbortEvent from ovos_workshop.decorators.killable import killable_event, \ AbortQuestion diff --git a/test/unittests/test_decorators.py b/test/unittests/test_decorators.py index bf22caa4..17619d3b 100644 --- a/test/unittests/test_decorators.py +++ b/test/unittests/test_decorators.py @@ -1,12 +1,90 @@ import json import unittest from os.path import dirname +from unittest.mock import Mock + from time import sleep from ovos_workshop.skill_launcher import SkillLoader from ovos_utils.messagebus import FakeBus, Message -from ovos_workshop.skills.mycroft_skill import is_classic_core + +class TestDecorators(unittest.TestCase): + def test_adds_context(self): + from ovos_workshop.decorators import adds_context + # TODO + + def test_removes_context(self): + from ovos_workshop.decorators import removes_context + # TODO + + def test_intent_handler(self): + from ovos_workshop.decorators import intent_handler + mock_intent = Mock() + called = False + + @intent_handler(mock_intent) + @intent_handler("test_intent") + def test_handler(): + nonlocal called + called = True + + self.assertEqual(test_handler.intents, ["test_intent", mock_intent]) + self.assertFalse(called) + + def test_resting_screen_handler(self): + from ovos_workshop.decorators import resting_screen_handler + called = False + + @resting_screen_handler("test_homescreen") + def show_homescreen(): + nonlocal called + called = True + + self.assertEqual(show_homescreen.resting_handler, "test_homescreen") + self.assertFalse(called) + + def test_skill_api_method(self): + from ovos_workshop.decorators import skill_api_method + called = False + + @skill_api_method + def api_method(): + nonlocal called + called = True + + self.assertTrue(api_method.api_method) + self.assertFalse(called) + + def test_converse_handler(self): + from ovos_workshop.decorators import converse_handler + called = False + + @converse_handler + def handle_converse(): + nonlocal called + called = True + + self.assertTrue(handle_converse.converse) + self.assertFalse(called) + + def test_fallback_handler(self): + from ovos_workshop.decorators import fallback_handler + called = False + + @fallback_handler() + def medium_prio_fallback(): + nonlocal called + called = True + + @fallback_handler(1) + def high_prio_fallback(): + nonlocal called + called = True + + self.assertEqual(medium_prio_fallback.fallback_priority, 50) + self.assertEqual(high_prio_fallback.fallback_priority, 1) + self.assertFalse(called) class TestKillableIntents(unittest.TestCase): @@ -208,22 +286,126 @@ def test_developer_stop_msg(self): sleep(2) self.assertTrue(self.bus.emitted_msgs == []) + def test_killable_event(self): + from ovos_workshop.decorators.killable import killable_event + # TODO -class TestConverse(unittest.TestCase): - # TODO - pass +class TestLayers(unittest.TestCase): + def test_dig_for_skill(self): + from ovos_workshop.decorators.layers import dig_for_skill + # TODO -class TestFallbackHandler(unittest.TestCase): - # TODO - pass + def test_enables_layer(self): + from ovos_workshop.decorators.layers import enables_layer + # TODO + def test_disables_layer(self): + from ovos_workshop.decorators.layers import disables_layer + # TODO -class TestLayers(unittest.TestCase): - # TODO - pass + def test_replaces_layer(self): + from ovos_workshop.decorators.layers import replaces_layer + # TODO + + def test_removes_layer(self): + from ovos_workshop.decorators.layers import removes_layer + # TODO + + def test_resets_layers(self): + from ovos_workshop.decorators.layers import resets_layers + # TODO + + def test_layer_intent(self): + from ovos_workshop.decorators.layers import layer_intent + # TODO + + def test_intent_layers(self): + from ovos_workshop.decorators.layers import IntentLayers + # TODO class TestOCP(unittest.TestCase): - # TODO - pass + def test_ocp_search(self): + from ovos_workshop.decorators.ocp import ocp_search + called = False + + @ocp_search() + def test_search(): + nonlocal called + called = True + + self.assertTrue(test_search.is_ocp_search_handler) + self.assertFalse(called) + + def test_ocp_play(self): + from ovos_workshop.decorators.ocp import ocp_play + called = False + + @ocp_play() + def test_play(): + nonlocal called + called = True + + self.assertTrue(test_play.is_ocp_playback_handler) + self.assertFalse(called) + + def test_ocp_previous(self): + from ovos_workshop.decorators.ocp import ocp_previous + called = False + + @ocp_previous() + def test_previous(): + nonlocal called + called = True + + self.assertTrue(test_previous.is_ocp_prev_handler) + self.assertFalse(called) + + def test_ocp_next(self): + from ovos_workshop.decorators.ocp import ocp_next + called = False + + @ocp_next() + def test_next(): + nonlocal called + called = True + + self.assertTrue(test_next.is_ocp_next_handler) + self.assertFalse(called) + + def test_ocp_pause(self): + from ovos_workshop.decorators.ocp import ocp_pause + called = False + + @ocp_pause() + def test_pause(): + nonlocal called + called = True + + self.assertTrue(test_pause.is_ocp_pause_handler) + self.assertFalse(called) + + def test_ocp_resume(self): + from ovos_workshop.decorators.ocp import ocp_resume + called = False + + @ocp_resume() + def test_resume(): + nonlocal called + called = True + + self.assertTrue(test_resume.is_ocp_resume_handler) + self.assertFalse(called) + + def test_ocp_featured_media(self): + from ovos_workshop.decorators.ocp import ocp_featured_media + called = False + + @ocp_featured_media() + def test_featured_media(): + nonlocal called + called = True + + self.assertTrue(test_featured_media.is_ocp_featured_handler) + self.assertFalse(called)