From f24d2783a7b974e3d1373d2cb43e825f46ba2586 Mon Sep 17 00:00:00 2001 From: JarbasAi Date: Fri, 29 Sep 2023 21:46:31 +0100 Subject: [PATCH] refactor/merge BaseSkill and OVOSSkill no functional changes! BaseSkill class removed, backwards compat import for OVOSSkill replace duplicate private methods from BaseSkill with the public methods from OVOSSkill reorder methods inside OVOSSkill by functionality, with comments for readability - skill developer abstract methods / devs are meant to override these - skill class related properties - safe skill_id/bus wrapper properties - magic properties -> depend on message.context / Session - resource file loading methods - skill object setup methods - skill internal events and lifecycle - intents / resource files management - event/intent registering internal handlers - skill developer facing utils - event related skill developer facing utils - intent/context skill developer facing utils - killable_events support - deprecated and marked for removal --- .gitignore | 1 + ovos_workshop/app.py | 4 +- ovos_workshop/decorators/layers.py | 10 +- ovos_workshop/skill_launcher.py | 2 +- ovos_workshop/skills/__init__.py | 6 +- ovos_workshop/skills/base.py | 2146 +--------------- ovos_workshop/skills/common_play.py | 4 +- ovos_workshop/skills/mycroft_skill.py | 114 +- ovos_workshop/skills/ovos.py | 2276 +++++++++++++++-- test/unittests/skills/test_base.py | 26 +- .../test_mycroft_skill/test_mycroft_skill.py | 8 +- .../test_mycroft_skill_get_response.py | 6 +- test/unittests/test_abstract_app.py | 19 +- 13 files changed, 2204 insertions(+), 2418 deletions(-) diff --git a/.gitignore b/.gitignore index 9595be50..c8ece8cf 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ dist # Created by unit tests .pytest_cache/ +/.gtm/ diff --git a/ovos_workshop/app.py b/ovos_workshop/app.py index 85b6b0b1..3045adf3 100644 --- a/ovos_workshop/app.py +++ b/ovos_workshop/app.py @@ -41,13 +41,13 @@ def __init__(self, skill_id: str, bus: Optional[MessageBusClient] = None, **kwargs) if settings: - log_deprecation(f"Settings should be set in {self._settings_path}. " + log_deprecation(f"Settings should be set in {self.settings_path}. " f"Passing `settings` to __init__ is not supported.", "0.1.0") self.settings.merge(settings) @property - def _settings_path(self) -> str: + def settings_path(self) -> str: """ Overrides the default path to put settings in `apps` subdirectory. """ diff --git a/ovos_workshop/decorators/layers.py b/ovos_workshop/decorators/layers.py index 569e6adc..9fda3578 100644 --- a/ovos_workshop/decorators/layers.py +++ b/ovos_workshop/decorators/layers.py @@ -4,7 +4,6 @@ from ovos_bus_client import MessageBusClient from ovos_utils.log import LOG -from ovos_workshop.skills.base import BaseSkill def dig_for_skill(max_records: int = 10) -> Optional[object]: @@ -13,17 +12,20 @@ def dig_for_skill(max_records: int = 10) -> Optional[object]: @param max_records: maximum number of records in the stack to check @return: Skill or AbstractApplication instance if found """ + + from ovos_workshop.skills.ovos import OVOSSkill + stack = inspect.stack()[1:] # First frame will be this function call stack = stack if len(stack) <= max_records else stack[:max_records] for record in stack: args = inspect.getargvalues(record.frame) if args.locals.get("self"): obj = args.locals["self"] - if isinstance(obj, BaseSkill): + if isinstance(obj, OVOSSkill): return obj elif args.locals.get("args"): for obj in args.locals["args"]: - if isinstance(obj, BaseSkill): + if isinstance(obj, OVOSSkill): return obj return None @@ -170,7 +172,7 @@ def bind(self, skill: object): return self @property - def skill(self) -> BaseSkill: + def skill(self): return self._skill @property diff --git a/ovos_workshop/skill_launcher.py b/ovos_workshop/skill_launcher.py index e5ca41ab..fadccfb9 100644 --- a/ovos_workshop/skill_launcher.py +++ b/ovos_workshop/skill_launcher.py @@ -484,7 +484,7 @@ def _create_skill_instance(self, log_deprecation("This initialization is deprecated. Update skill to" "handle passed `skill_id` and `bus` kwargs", "0.1.0") - if not self.instance._is_fully_initialized: + if not self.instance.is_fully_initialized: self.instance._startup(self.bus, self.skill_id) except Exception as e: LOG.exception(f'Skill __init__ failed with {e}') diff --git a/ovos_workshop/skills/__init__.py b/ovos_workshop/skills/__init__.py index 77715868..eaacb75c 100644 --- a/ovos_workshop/skills/__init__.py +++ b/ovos_workshop/skills/__init__.py @@ -1,3 +1,5 @@ -from ovos_workshop.skills.ovos import MycroftSkill, OVOSSkill, OVOSFallbackSkill -from ovos_workshop.skills.idle_display_skill import IdleDisplaySkill from ovos_workshop.decorators.layers import IntentLayers +from ovos_workshop.skills.ovos import OVOSSkill, OVOSFallbackSkill +from ovos_workshop.skills.idle_display_skill import IdleDisplaySkill +from ovos_workshop.skills.mycroft_skill import MycroftSkill + diff --git a/ovos_workshop/skills/base.py b/ovos_workshop/skills/base.py index dca29406..4a8195c4 100644 --- a/ovos_workshop/skills/base.py +++ b/ovos_workshop/skills/base.py @@ -1,79 +1,7 @@ -# Copyright 2019 Mycroft AI Inc. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# -"""Common functionality relating to the implementation of mycroft skills.""" -import datetime -import re -import sys -import time -import traceback -from copy import copy -from hashlib import md5 -from inspect import signature -from itertools import chain -from os.path import join, abspath, dirname, basename, isfile -from threading import Event, RLock -from typing import List, Optional, Dict, Callable, Union - -from json_database import JsonStorage -from lingua_franca.format import pronounce_number, join_list -from lingua_franca.parse import yes_or_no, extract_number -from ovos_backend_client.api import EmailApi, MetricsApi -from ovos_config.config import Configuration -from ovos_config.locations import get_xdg_config_save_path - -from ovos_bus_client import MessageBusClient -from ovos_bus_client.message import Message, dig_for_message -from ovos_bus_client.session import SessionManager -from ovos_utils import camel_case_split -from ovos_utils import classproperty -from ovos_utils.dialog import get_dialog, MustacheDialogRenderer -from ovos_utils.enclosure.api import EnclosureAPI -from ovos_utils.events import EventContainer, EventSchedulerInterface -from ovos_utils.file_utils import FileWatcher -from ovos_utils.gui import GUIInterface, get_ui_directories -from ovos_utils.intents import ConverseTracker -from ovos_utils.intents import Intent, IntentBuilder -from ovos_utils.intents.intent_service_interface import munge_regex, \ - munge_intent_parser, IntentServiceInterface -from ovos_utils.json_helper import merge_dict -from ovos_utils.log import LOG, deprecated, log_deprecation -from ovos_utils.messagebus import get_handler_name, create_wrapper, \ - get_message_lang -from ovos_utils.parse import match_one +# DEPRECATED - merged into OVOSSkill, imports for compat onlu +from ovos_workshop.skills.ovos import OVOSSkill, simple_trace, is_classic_core, SkillGUI +from ovos_utils.log import log_deprecation from ovos_utils.process_utils import RuntimeRequirements -from ovos_utils.skills import get_non_properties -from ovos_workshop.decorators.compat import backwards_compat -from ovos_workshop.decorators.killable import AbortEvent -from ovos_workshop.decorators.killable import killable_event, \ - AbortQuestion -from ovos_workshop.filesystem import FileSystemAccess -from ovos_workshop.resource_files import ResourceFile, \ - CoreResources, SkillResources, find_resource -from ovos_workshop.settings import SkillSettingsManager - - -def is_classic_core(): - try: - from mycroft.version import OVOS_VERSION_STR - return False - except: - try: - import mycroft - return True - except: - return False # backwards compat alias @@ -85,2071 +13,5 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) -def simple_trace(stack_trace: List[str]) -> str: - """ - Generate a simplified traceback. - @param stack_trace: Formatted stack trace (each string ends with \n) - @return: Stack trace with any empty lines removed and last line removed - """ - stack_trace = stack_trace[:-1] - tb = 'Traceback:\n' - for line in stack_trace: - if line.strip(): - tb += line - return tb - - -class BaseSkill: - """ - Base class for mycroft skills providing common behaviour and parameters - to all Skill implementations. This base class does not require `mycroft` to - be importable - - skill_launcher.py used to be skill_loader-py in mycroft-core - - for launching skills one can use skill_launcher.py to run them standalone - (eg, docker), but the main objective is to make skills work more like proper - python objects and allow usage of the class directly - - the considerations are: - - - most skills in the wild don't expose kwargs, so don't accept - skill_id or bus - - most skills expect a loader class to set up the bus and skill_id after - object creation - - skills can not do pythonic things in init, instead of doing things after - super() devs are expected to use initialize() which is a mycroft invention - and non-standard - - main concern is that anything depending on self.skill_id being set can not - be used in init method (eg. self.settings and self.file_system) - - __new__ uncouples the skill init from a helper class, making skills work - like regular python objects - - the magic in `__new__` is just so we don't break everything in the wild, - since we cant start requiring skill_id and bus args - - KwArgs: - name (str): skill name - DEPRECATED - skill_id (str): unique skill identifier - bus (MycroftWebsocketClient): Optional bus connection - """ - - def __init__(self, name: Optional[str] = None, - bus: Optional[MessageBusClient] = None, - resources_dir: Optional[str] = None, - settings: Optional[JsonStorage] = None, - gui: Optional[GUIInterface] = None, - enable_settings_manager: bool = True, - skill_id: str = ""): - """ - Create an OVOSSkill object. - @param name: DEPRECATED skill_name - @param bus: MessageBusClient to bind to skill - @param resources_dir: optional root resource directory (else defaults to - skill `root_dir` - @param settings: Optional settings object, else defined in skill config - path - @param gui: Optional SkillGUI, else one is initialized - @param enable_settings_manager: if True, enables a SettingsManager for - this skill to manage default settings and backend sync - @param skill_id: Unique ID for this skill - """ - - self.log = LOG # a dedicated namespace will be assigned in _startup - self._enable_settings_manager = enable_settings_manager - self._init_event = Event() - self.name = name or self.__class__.__name__ - self.resting_name = None - self.skill_id = skill_id # set by SkillLoader, guaranteed unique - self._settings_meta = None # DEPRECATED - backwards compat only - self.settings_manager = None - - # Get directory of skill source (__init__.py) - self.root_dir = dirname(abspath(sys.modules[self.__module__].__file__)) - self.res_dir = resources_dir or self.root_dir - - self.gui = gui - self._bus = bus - self._enclosure = EnclosureAPI() - - # Core configuration - self.config_core: Configuration = Configuration() - - self._settings = None - self._initial_settings = settings or dict() - self._settings_watchdog = None - self._settings_lock = RLock() - - # Override to register a callback method that will be called every time - # the skill's settings are updated. The referenced method should - # include any logic needed to handle the updated settings. - self.settings_change_callback = None - - # fully initialized when self.skill_id is set - self._file_system = None - - self.reload_skill = True # allow reloading (default True) - - self.events = EventContainer(bus) - - # Cached voc file contents - self._voc_cache = {} - - # loaded lang file resources - self._lang_resources = {} - - # Delegator classes - self.event_scheduler = EventSchedulerInterface() - self.intent_service = IntentServiceInterface() - - # Skill Public API - self.public_api: Dict[str, dict] = {} - - self.__original_converse = self.converse - - # yay, following python best practices again! - if self.skill_id and bus: - self._startup(bus, self.skill_id) - - @classproperty - def runtime_requirements(self) -> RuntimeRequirements: - """ - Override to specify what a skill expects to be available at init and at - runtime. Default will assume network and internet are required and GUI - is not required for backwards-compat. - - some examples: - - IOT skill that controls skills via LAN could return: - scans_on_init = True - RuntimeRequirements(internet_before_load=False, - network_before_load=scans_on_init, - requires_internet=False, - requires_network=True, - no_internet_fallback=True, - no_network_fallback=False) - - online search skill with a local cache: - has_cache = False - RuntimeRequirements(internet_before_load=not has_cache, - network_before_load=not has_cache, - requires_internet=True, - requires_network=True, - no_internet_fallback=True, - no_network_fallback=True) - - a fully offline skill: - RuntimeRequirements(internet_before_load=False, - network_before_load=False, - requires_internet=False, - requires_network=False, - no_internet_fallback=True, - no_network_fallback=True) - """ - return RuntimeRequirements() - - @classproperty - def network_requirements(self) -> RuntimeRequirements: - LOG.warning("network_requirements renamed to runtime_requirements, " - "will be removed in ovos-core 0.0.8") - return self.runtime_requirements - - @property - def voc_match_cache(self) -> Dict[str, List[str]]: - """ - Backwards-compatible accessor method for vocab cache - @return: dict vocab resources to parsed resources - """ - return self._voc_cache - - @voc_match_cache.setter - def voc_match_cache(self, val): - self.log.warning("self._voc_cache should not be modified externally. This" - "functionality will be deprecated in a future release") - if isinstance(val, dict): - self._voc_cache = val - - # not a property in mycroft-core - @property - def _is_fully_initialized(self) -> bool: - """ - Determines if the skill has been fully loaded and setup. - When True, all data has been loaded and all internal state - and events set up. - """ - return self._init_event.is_set() - - # not a property in mycroft-core - @property - def _settings_path(self) -> str: - """ - Absolute file path of this skill's `settings.json` (file may not exist) - """ - return join(get_xdg_config_save_path(), 'skills', self.skill_id, - 'settings.json') - - # not a property in mycroft-core - @property - def settings(self) -> JsonStorage: - """ - Get settings specific to this skill - """ - if self._settings is not None: - return self._settings - else: - self.log.warning('Skill not fully initialized. Only default values ' - 'can be set, no settings can be read or changed.' - f"to correct this add kwargs " - f"__init__(bus=None, skill_id='') " - f"to skill class {self.__class__.__name__}") - self.log.error(simple_trace(traceback.format_stack())) - return self._initial_settings - - # not a property in mycroft-core - @settings.setter - def settings(self, val: dict): - """ - Update settings specific to this skill - """ - assert isinstance(val, dict) - # init method - if self._settings is None: - self._initial_settings = val - return - with self._settings_lock: - # ensure self._settings remains a JsonDatabase - self._settings.clear() # clear data - self._settings.merge(val, skip_empty=False) # merge new data - - # not a property in mycroft-core - @property - def dialog_renderer(self) -> Optional[MustacheDialogRenderer]: - """ - Get a dialog renderer for this skill. Language will be determined by - message history to match the language associated with the current - session or else from Configuration. - """ - return self._resources.dialog_renderer - - @property - def enclosure(self) -> EnclosureAPI: - """ - Get an EnclosureAPI object to interact with hardware - """ - if self._enclosure: - return self._enclosure - else: - self.log.warning('Skill not fully initialized.' - f"to correct this add kwargs " - f"__init__(bus=None, skill_id='') " - f"to skill class {self.__class__.__name__}") - self.log.error(simple_trace(traceback.format_stack())) - raise Exception('Accessed MycroftSkill.enclosure in __init__') - - # not a property in mycroft-core - @property - def file_system(self) -> FileSystemAccess: - """ - Get an object that provides managed access to a local Filesystem. - """ - if not self._file_system and self.skill_id: - self._file_system = FileSystemAccess(join('skills', self.skill_id)) - if self._file_system: - return self._file_system - else: - self.log.warning('Skill not fully initialized.' - f"to correct this add kwargs __init__(bus=None, skill_id='') " - f"to skill class {self.__class__.__name__}") - self.log.error(simple_trace(traceback.format_stack())) - raise Exception('Accessed MycroftSkill.file_system in __init__') - - @file_system.setter - def file_system(self, fs: FileSystemAccess): - """ - Provided mainly for backwards compatibility with derivative - MycroftSkill classes. Skills are advised against redefining the file - system directory. - @param fs: new FileSystemAccess object to use - """ - self.log.warning(f"Skill manually overriding file_system path to: " - f"{fs.path}") - self._file_system = fs - - @property - def bus(self) -> MessageBusClient: - """ - Get the MessageBusClient bound to this skill - """ - if self._bus: - return self._bus - else: - self.log.warning('Skill not fully initialized.' - f"to correct this add kwargs " - f"__init__(bus=None, skill_id='') " - f"to skill class {self.__class__.__name__}") - self.log.error(simple_trace(traceback.format_stack())) - raise Exception('Accessed MycroftSkill.bus in __init__') - - @bus.setter - def bus(self, value: MessageBusClient): - """ - Set the MessageBusClient bound to this skill. Note that setting this - after init may have unintended consequences as expected events might - not be registered. Call `bind` to connect a new MessageBusClient. - @param value: new MessageBusClient object - """ - from ovos_bus_client import MessageBusClient - from ovos_utils.messagebus import FakeBus - if isinstance(value, (MessageBusClient, FakeBus)): - self._bus = value - else: - raise TypeError(f"Expected a MessageBusClient, got: {type(value)}") - - @property - def location(self) -> dict: - """ - Get the JSON data struction holding location information. - """ - # TODO: Allow Enclosure to override this for devices that - # contain a GPS. - return self.config_core.get('location') - - @property - def location_pretty(self) -> Optional[str]: - """ - Get a speakable city from the location config if available - """ - loc = self.location - if type(loc) is dict and loc['city']: - return loc['city']['name'] - return None - - @property - def location_timezone(self) -> Optional[str]: - """ - Get the timezone code, such as 'America/Los_Angeles' - """ - loc = self.location - if type(loc) is dict and loc['timezone']: - return loc['timezone']['code'] - return None - - @property - def lang(self) -> str: - """ - Get the current language as a BCP-47 language code. This will consider - current session data if available, else Configuration. - """ - lang = self._core_lang - message = dig_for_message() - if message: - lang = get_message_lang(message) - return lang.lower() - - # property not present in mycroft-core - @property - def _core_lang(self) -> str: - """ - Get the configured default language as a BCP-47 language code. - - NOTE: this should be public, but since if a skill uses this it won't - work in regular mycroft-core it was made private! - """ - return self.config_core.get("lang", "en-us").lower() - - # property not present in mycroft-core - @property - def _secondary_langs(self) -> List[str]: - """ - Get the configured secondary languages; resources will be loaded for - these languages to provide support for multilingual input, in addition - to `core_lang`. A skill may override this method to specify which - languages intents are registered in. - - NOTE: this should be public, but since if a skill uses this it won't - work in regular mycroft-core it was made private! - """ - return [lang.lower() for lang in self.config_core.get( - 'secondary_langs', []) if lang != self._core_lang] - - # property not present in mycroft-core - @property - def _native_langs(self) -> List[str]: - """ - Languages natively supported by this skill (ie, resource files available - and explicitly supported). This is equivalent to normalized - secondary_langs + core_lang. - - NOTE: this should be public, but since if a skill uses this it won't - work in regular mycroft-core it was made private! - """ - valid = set([lang.lower() for lang in self._secondary_langs if '-' in - lang and lang != self._core_lang] + [self._core_lang]) - return list(valid) - - # property not present in mycroft-core - @property - def _alphanumeric_skill_id(self) -> str: - """ - Skill id converted to only alphanumeric characters and "_". - Non alphanumeric characters are converted to "_" - - NOTE: this should be public, but since if a skill uses this it won't - work in regular mycroft-core it was made private! - """ - return ''.join(c if c.isalnum() else '_' - for c in str(self.skill_id)) - - # property not present in mycroft-core - @property - def _resources(self) -> SkillResources: - """ - Get a SkillResources object for the current language. Objects are - initialized for the current language as needed. - - NOTE: this should be public, but since if a skill uses this it won't - work in regular mycroft-core it was made private! - """ - return self._load_lang(self.res_dir, self.lang) - - # property not present in mycroft-core - @property - def _stop_is_implemented(self) -> bool: - """ - True if this skill implements a `stop` method - """ - return self.__class__.stop is not BaseSkill.stop - - # property not present in mycroft-core - @property - def _converse_is_implemented(self) -> bool: - """ - True if this skill implements a `converse` method - """ - return self.__class__.converse is not BaseSkill.converse or \ - self.__original_converse != self.converse - - # method not present in mycroft-core - def _handle_first_run(self): - """ - The very first time a skill is run, speak a provided intro_message. - """ - intro = self.get_intro_message() - if intro: - # supports .dialog files for easy localization - # when .dialog does not exist, the text is spoken - # it is backwards compatible - self.speak_dialog(intro) - - # method not present in mycroft-core - def _check_for_first_run(self): - """ - Determine if this is the very first time a skill is run by looking for - `__mycroft_skill_firstrun` in skill settings. - """ - first_run = self.settings.get("__mycroft_skill_firstrun", True) - if first_run: - self.log.info("First run of " + self.skill_id) - self._handle_first_run() - self.settings["__mycroft_skill_firstrun"] = False - self.settings.store() - - def _startup(self, bus: MessageBusClient, skill_id: str = ""): - """ - Startup the skill. Connects the skill to the messagebus, loads resources - and finally calls the skill's "intialize" method. - @param bus: MessageBusClient to bind to skill - @param skill_id: Unique skill identifier, defaults to skill path for - legacy skills and python entrypoints for modern skills - """ - if self._is_fully_initialized: - self.log.warning(f"Tried to initialize {self.skill_id} multiple " - f"times, ignoring") - return - - # NOTE: this method is called by SkillLoader - # it is private to make it clear to skill devs they should not touch it - try: - # set the skill_id - self.skill_id = skill_id or basename(self.root_dir) - self.intent_service.set_id(self.skill_id) - self.event_scheduler.set_id(self.skill_id) - self.enclosure.set_id(self.skill_id) - - # initialize anything that depends on skill_id - self.log = LOG.create_logger(self.skill_id) - self._init_settings() - - # initialize anything that depends on the messagebus - self.bind(bus) - if not self.gui: - self._init_skill_gui() - if self._enable_settings_manager: - self._init_settings_manager() - self.load_data_files() - self._register_decorated() - self.register_resting_screen() - - # run skill developer initialization code - self.initialize() - self._check_for_first_run() - self._init_event.set() - except Exception as e: - self.log.exception('Skill initialization failed') - # If an exception occurs, attempt to clean up the skill - try: - self.default_shutdown() - except Exception as e2: - LOG.debug(e2) - raise e - - def _init_settings(self): - """ - Set up skill settings. Defines settings in the specified file path, - handles any settings passed to skill init, and starts watching the - settings file for changes. - """ - self.log.debug(f"initializing skill settings for {self.skill_id}") - - # NOTE: lock is disabled due to usage of deepcopy and to allow json - # serialization - self._settings = JsonStorage(self._settings_path, disable_lock=True) - with self._settings_lock: - if self._initial_settings and not self._is_fully_initialized: - self.log.warning("Copying default settings values defined in " - "__init__ \nto correct this add kwargs " - "__init__(bus=None, skill_id='') " - f"to skill class {self.__class__.__name__}") - for k, v in self._initial_settings.items(): - if k not in self._settings: - self._settings[k] = v - self._initial_settings = copy(self.settings) - - self._start_filewatcher() - - def _init_skill_gui(self): - """ - Set up the SkillGUI for this skill and connect relevant bus events. - """ - self.gui = SkillGUI(self) - self.gui.setup_default_handlers() - - def _init_settings_manager(self): - """ - Set up the SkillSettingsManager for this skill. - """ - self.settings_manager = SkillSettingsManager(self) - - def _start_filewatcher(self): - """ - Start watching settings for file changes if settings file exists and - there isn't already a FileWatcher watching it - """ - if self._settings_watchdog is None and isfile(self._settings.path): - self._settings_watchdog = \ - FileWatcher([self._settings.path], - callback=self._handle_settings_file_change, - ignore_creation=True) - - # method not present in mycroft-core - def _upload_settings(self): - """ - Upload settings to a remote backend if configured. - """ - if self.settings_manager and self.config_core.get("skills", - {}).get("sync2way"): - # upload new settings to backend - generate = self.config_core.get("skills", {}).get("autogen_meta", - True) - # this will check global sync flag - self.settings_manager.upload(generate) - if generate: - # update settingsmeta file on disk - self.settings_manager.save_meta() - - # method not present in mycroft-core - def _handle_settings_file_change(self, path: str): - """ - Handle a FileWatcher notification that a file was changed. Reload - settings, call `self.settings_change_callback` if defined, and upload - changes if a backend is configured. - @param path: Modified file path - """ - if path != self._settings.path: - LOG.debug(f"Ignoring non-settings change") - return - if self._settings: - with self._settings_lock: - self._settings.reload() - if self.settings_change_callback: - try: - self.settings_change_callback() - except Exception as e: - self.log.exception("settings change callback failed, " - f"file changes not handled!: {e}") - self._upload_settings() - - # method not present in mycroft-core - def _load_lang(self, root_directory: Optional[str] = None, - lang: Optional[str] = None) -> SkillResources: - """ - Get a SkillResources object for this skill in the requested `lang` for - resource files in the requested `root_directory`. - @param root_directory: root path to find resources (default res_dir) - @param lang: language to get resources for (default self.lang) - @return: SkillResources object - - NOTE: this should be public, but since if a skill uses this it won't - work in regular mycroft-core it was made private! - """ - lang = lang or self.lang - root_directory = root_directory or self.res_dir - if lang not in self._lang_resources: - self._lang_resources[lang] = SkillResources(root_directory, lang, - skill_id=self.skill_id) - return self._lang_resources[lang] - - def __bind_classic(self, bus): - self._bus = bus - self.events.set_bus(bus) - self.intent_service.set_bus(bus) - self.event_scheduler.set_bus(bus) - self._enclosure.set_bus(bus) - self._register_system_event_handlers() - self._register_public_api() - log_deprecation("Support for mycroft-core is deprecated", - "0.1.0") - # inject ovos exclusive features in vanilla mycroft-core - # if possible - # limited support for missing skill deactivated event - # TODO - update ConverseTracker - ConverseTracker.connect_bus(self.bus) # pull/1468 - self.add_event("converse.skill.deactivated", - self._handle_skill_deactivated, - speak_errors=False) - - @backwards_compat(classic_core=__bind_classic) - def bind(self, bus: MessageBusClient): - """ - Register MessageBusClient with skill. - @param bus: MessageBusClient to bind to skill and internal objects - """ - if bus: - self._bus = bus - self.events.set_bus(bus) - self.intent_service.set_bus(bus) - self.event_scheduler.set_bus(bus) - self._enclosure.set_bus(bus) - self._register_system_event_handlers() - self._register_public_api() - - def _register_public_api(self): - """ - Find and register API methods decorated with `@api_method` and create a - messagebus handler for fetching the api info if any handlers exist. - """ - - def wrap_method(fn): - """Boilerplate for returning the response to the sender.""" - - def wrapper(message): - result = fn(*message.data['args'], **message.data['kwargs']) - message.context["skill_id"] = self.skill_id - self.bus.emit(message.response(data={'result': result})) - - return wrapper - - methods = [attr_name for attr_name in get_non_properties(self) - if hasattr(getattr(self, attr_name), '__name__')] - - for attr_name in methods: - method = getattr(self, attr_name) - - if hasattr(method, 'api_method'): - doc = method.__doc__ or '' - name = method.__name__ - self.public_api[name] = { - 'help': doc, - 'type': f'{self.skill_id}.{name}', - 'func': method - } - for key in self.public_api: - if ('type' in self.public_api[key] and - 'func' in self.public_api[key]): - self.log.debug(f"Adding api method: " - f"{self.public_api[key]['type']}") - - # remove the function member since it shouldn't be - # reused and can't be sent over the messagebus - func = self.public_api[key].pop('func') - self.add_event(self.public_api[key]['type'], - wrap_method(func), speak_errors=False) - - if self.public_api: - self.add_event(f'{self.skill_id}.public_api', - self._send_public_api, speak_errors=False) - - def _register_system_event_handlers(self): - """ - Register default messagebus event handlers - """ - # Only register stop if it's been implemented - if self._stop_is_implemented: - self.add_event('mycroft.stop', self.__handle_stop, - speak_errors=False) - self.add_event('skill.converse.ping', self._handle_converse_ack, - speak_errors=False) - self.add_event('skill.converse.request', self._handle_converse_request, - speak_errors=False) - self.add_event(f"{self.skill_id}.activate", self.handle_activate, - speak_errors=False) - self.add_event(f"{self.skill_id}.deactivate", self.handle_deactivate, - speak_errors=False) - self.add_event("intent.service.skills.deactivated", - self._handle_skill_deactivated, speak_errors=False) - self.add_event("intent.service.skills.activated", - self._handle_skill_activated, speak_errors=False) - self.add_event('mycroft.skill.enable_intent', self.handle_enable_intent, - speak_errors=False) - self.add_event('mycroft.skill.disable_intent', - self.handle_disable_intent, speak_errors=False) - self.add_event('mycroft.skill.set_cross_context', - self.handle_set_cross_context, speak_errors=False) - self.add_event('mycroft.skill.remove_cross_context', - self.handle_remove_cross_context, speak_errors=False) - self.add_event('mycroft.skills.settings.changed', - self.handle_settings_change, speak_errors=False) - - def handle_settings_change(self, message: Message): - """ - Update settings if a remote settings changes apply to this skill. - - The skill settings downloader uses a single API call to retrieve the - settings for all skills to limit the number API calls. - A "mycroft.skills.settings.changed" event is emitted for each skill - with settings changes. Only update this skill's settings if its remote - settings were among those changed. - """ - remote_settings = message.data.get(self.skill_id) - if remote_settings is not None: - self.log.info('Updating settings for skill ' + self.skill_id) - self.settings.update(**remote_settings) - self.settings.store() - if self.settings_change_callback is not None: - try: - self.settings_change_callback() - except Exception as e: - self.log.exception("settings change callback failed, " - f"remote changes not handled!: {e}") - self._start_filewatcher() - - def detach(self): - """ - Detach all intents for this skill from the intent_service. - """ - for (name, _) in self.intent_service: - name = f'{self.skill_id}:{name}' - self.intent_service.detach_intent(name) - - def initialize(self): - """ - Legacy method overridden by skills to perform extra init after __init__. - Skills should now move any code in this method to `__init__`, after a - call to `super().__init__`. - """ - pass - - def _send_public_api(self, message: Message): - """ - Respond with the skill's public api. - @param message: `{self.skill_id}.public_api` Message - """ - message.context["skill_id"] = self.skill_id - self.bus.emit(message.response(data=self.public_api)) - - def get_intro_message(self) -> str: - """ - Override to return a string to speak on first run. i.e. for post-install - setup instructions. - """ - return "" - - # method not present in mycroft-core - def _handle_skill_activated(self, message: Message): - """ - Intent service activated a skill. If it was this skill, - emit a skill activation message. - @param message: `intent.service.skills.activated` Message - """ - if message.data.get("skill_id") == self.skill_id: - self.bus.emit(message.forward(f"{self.skill_id}.activate")) - - # method not present in mycroft-core - def handle_activate(self, message: Message): - """ - Called when this skill is considered active by the intent service; - converse method will be called with every utterance. - Override this method to do any optional preparation. - @param message: `{self.skill_id}.activate` Message - """ - - # method not present in mycroft-core - def _handle_skill_deactivated(self, message): - """ - Intent service deactivated a skill. If it was this skill, - emit a skill deactivation message. - @param message: `intent.service.skills.deactivated` Message - """ - if message.data.get("skill_id") == self.skill_id: - self.bus.emit(message.forward(f"{self.skill_id}.deactivate")) - - # method not present in mycroft-core - def handle_deactivate(self, message): - """ - Called when this skill is no longer considered active by the intent - service; converse method will not be called until skill is active again. - Override this method to do any optional cleanup. - @param message: `{self.skill_id}.deactivate` Message - """ - - # named make_active in mycroft-core - def _activate(self): - """ - Mark this skill as active and push to the top of the active skills list. - This enables converse method to be called even without skill being - used in last 5 minutes. - """ - msg = dig_for_message() or Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - - m1 = msg.forward("intent.service.skills.activate", - data={"skill_id": self.skill_id}) - self.bus.emit(m1) - - # backwards compat with mycroft-core - # TODO - remove soon - m2 = msg.forward("active_skill_request", - data={"skill_id": self.skill_id}) - self.bus.emit(m2) - - # method not present in mycroft-core - def _deactivate(self): - """ - Mark this skill as inactive and remove from the active skills list. - This stops converse method from being called. - """ - msg = dig_for_message() or Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward(f"intent.service.skills.deactivate", - data={"skill_id": self.skill_id})) - - def _handle_converse_ack(self, message: Message): - """ - Inform skills service if we want to handle converse. Individual skills - may override the property self.converse_is_implemented to enable or - disable converse support. Note that this does not affect a skill's - `active` status. - @param message: `skill.converse.ping` Message - """ - self.bus.emit(message.reply( - "skill.converse.pong", - data={"skill_id": self.skill_id, - "can_handle": self._converse_is_implemented}, - context={"skill_id": self.skill_id})) - - # method not present in mycroft-core - def _handle_converse_request(self, message: Message): - """ - If this skill is requested and supports converse, handle the user input - with `converse`. - @param message: `skill.converse.request` Message - """ - skill_id = message.data['skill_id'] - if skill_id == self.skill_id: - try: - # converse can have multiple signatures - params = signature(self.converse).parameters - kwargs = {"message": message, - "utterances": message.data['utterances'], - "lang": message.data['lang']} - kwargs = {k: v for k, v in kwargs.items() if k in params} - result = self.converse(**kwargs) - self.bus.emit(message.reply('skill.converse.response', - {"skill_id": self.skill_id, - "result": result})) - except Exception as e: - LOG.error(e) - self.bus.emit(message.reply('skill.converse.response', - {"skill_id": self.skill_id, - "result": False})) - - def converse(self, message: Optional[Message] = None) -> bool: - """ - Override to handle an utterance before intent parsing while this skill - is active. Active skills are called in order of most recently used to - least recently used until one handles the converse request. If no skill - handles an utterance in `converse`, then the utterance will continue to - normal intent parsing. - @param message: Message containing user utterances to optionally handle - @return: True if the utterance was handled, else False - """ - return False - - def __get_response(self): - """ - Helper to get a response from the user - - NOTE: There is a race condition here. There is a small amount of - time between the end of the device speaking and the converse method - being overridden in this method. If an utterance is injected during - this time, the wrong converse method is executed. The condition is - hidden during normal use due to the amount of time it takes a user - to speak a response. The condition is revealed when an automated - process injects an utterance quicker than this method can flip the - converse methods. - - Returns: - str: user's response or None on a timeout - """ - - # TODO: Support `message` signature like default? - def converse(utterances, lang=None): - converse.response = utterances[0] if utterances else None - converse.finished = True - return True - - # install a temporary conversation handler - self._activate() - converse.finished = False - converse.response = None - self.converse = converse - - # 10 for listener, 5 for SST, then timeout - # NOTE: a threading.Event is not used otherwise we can't raise the - # AbortEvent exception to kill the thread - start = time.time() - while time.time() - start <= 15 and not converse.finished: - # TODO: Refactor to event-based handling - time.sleep(0.1) - if self.__response is not False: - if self.__response is None: - # aborted externally (if None) - self.log.debug("get_response aborted") - converse.finished = True - converse.response = self.__response # external override - self.converse = self.__original_converse - return converse.response - - def get_response(self, dialog: str = '', data: Optional[dict] = None, - validator: Optional[Callable[[str], bool]] = None, - on_fail: Optional[Union[str, Callable[[str], str]]] = None, - num_retries: int = -1) -> Optional[str]: - """ - Get a response from the user. If a dialog is supplied it is spoken, - followed immediately by listening for a user response. If the dialog is - omitted, listening is started directly. The response may optionally be - validated before returning. - @param dialog: Optional dialog resource or string to speak - @param data: Optional data to render dialog with - @param validator: Optional method to validate user input with. Accepts - the user's utterance as an arg and returns True if it is valid. - @param on_fail: Optional string or method that accepts a failing - utterance and returns a string to be spoken when validation fails. - @param num_retries: Number of times to retry getting a user response; - -1 will retry infinitely. - * If the user asks to "cancel", this method will exit - * If the user doesn't respond and this is `-1` this will only retry - once. - @return: String user response (None if no valid response is given) - """ - data = data or {} - - def on_fail_default(utterance): - fail_data = data.copy() - fail_data['utterance'] = utterance - if on_fail: - if self.dialog_renderer: - return self.dialog_renderer.render(on_fail, fail_data) - return on_fail - else: - if self.dialog_renderer: - return self.dialog_renderer.render(dialog, data) - return dialog - - def is_cancel(utterance): - return self.voc_match(utterance, 'cancel') - - def validator_default(utterance): - # accept anything except 'cancel' - return not is_cancel(utterance) - - on_fail_fn = on_fail if callable(on_fail) else on_fail_default - validator = validator or validator_default - - # Speak query and wait for user response - if dialog: - self.speak_dialog(dialog, data, expect_response=True, wait=True) - else: - msg = dig_for_message() - msg = msg.reply('mycroft.mic.listen') if msg else \ - Message('mycroft.mic.listen', - context={"skill_id": self.skill_id}) - self.bus.emit(msg) - return self._wait_response(is_cancel, validator, on_fail_fn, - num_retries) - - def _wait_response(self, is_cancel: callable, validator: callable, - on_fail: callable, num_retries: int) -> Optional[str]: - """ - Loop until a valid response is received from the user or the retry - limit is reached. - @param is_cancel: Function that returns `True` if user asked to cancel - @param validator: Function that returns `True` if user input is valid - @param on_fail: Function to call if validator returns `False` - @param num_retries: Number of times to retry getting a response - @returns: User response if validated, else None - """ - self.__response = False - self._real_wait_response(is_cancel, validator, on_fail, num_retries) - while self.__response is False: - # TODO: Refactor to Event - time.sleep(0.1) - return self.__response or None - - def _handle_killed_wait_response(self): - """ - Handle "stop" request when getting a response. - """ - self.__response = None - self.converse = self.__original_converse - - @killable_event("mycroft.skills.abort_question", exc=AbortQuestion, - callback=_handle_killed_wait_response, react_to_stop=True) - def _real_wait_response(self, is_cancel, validator, on_fail, num_retries): - """ - Loop until a valid response is received from the user or the retry - limit is reached. - - Arguments: - is_cancel (callable): function checking cancel criteria - validator (callbale): function checking for a valid response - on_fail (callable): function handling retries - - """ - msg = dig_for_message() - msg = msg.reply('mycroft.mic.listen') if msg else \ - Message('mycroft.mic.listen', - context={"skill_id": self.skill_id}) - - num_fails = 0 - while True: - if self.__response is not False: - # usually None when aborted externally - # also allows overriding returned result from other events - return self.__response - - response = self.__get_response() - - if response is None: - # if nothing said, prompt one more time - num_none_fails = 1 if num_retries < 0 else num_retries - if num_fails >= num_none_fails: - self.__response = None - return - else: - # catch user saying 'cancel' - if is_cancel(response): - self.__response = None - return - - validated = validator(response) - # returns the validated value or the response - # (backwards compat) - if validated is not False and validated is not None: - self.__response = response if validated is True else validated - return - - num_fails += 1 - if 0 < num_retries < num_fails or self.__response is not False: - self.__response = None - return - - line = on_fail(response) - if line: - self.speak(line, expect_response=True) - else: - self.bus.emit(msg) - - @staticmethod - def acknowledge(): - """ - Acknowledge a successful request. - - This method plays a sound to acknowledge a request that does not - require a verbal response. This is intended to provide simple feedback - to the user that their request was handled successfully. - """ - # DEPRECATED - note that this is a staticmethod and uses the old endpoint - # the OVOSSkill class does things properly - from ovos_utils.sound import play_acknowledge_sound - return play_acknowledge_sound() - - def ask_yesno(self, prompt: str, - data: Optional[dict] = None) -> Optional[str]: - """ - Read prompt and wait for a yes/no answer. This automatically deals with - translation and common variants, such as 'yeah', 'sure', etc. - @param prompt: a dialog id or string to read - @param data: optional data to render dialog with - @return: 'yes', 'no' or the user response if not matched to 'yes' or - 'no', including a response of None. - """ - resp = self.get_response(dialog=prompt, data=data) - answer = yes_or_no(resp, lang=self.lang) if resp else resp - if answer is True: - return "yes" - elif answer is False: - return "no" - else: - return resp - - def ask_selection(self, options: List[str], dialog: str = '', - data: Optional[dict] = None, min_conf: float = 0.65, - numeric: bool = False): - """ - Read options, ask dialog question and wait for an answer. - - This automatically deals with fuzzy matching and selection by number - e.g. - - * "first option" - * "last option" - * "second option" - * "option number four" - - Args: - options (list): list of options to present user - dialog (str): a dialog id or string to read AFTER all options - data (dict): Data used to render the dialog - min_conf (float): minimum confidence for fuzzy match, if not - reached return None - numeric (bool): speak options as a numeric menu - Returns: - string: list element selected by user, or None - """ - assert isinstance(options, list) - - if not len(options): - return None - elif len(options) == 1: - return options[0] - - if numeric: - for idx, opt in enumerate(options): - number = pronounce_number(idx + 1, self.lang) - self.speak(f"{number}, {opt}", wait=True) - else: - opt_str = join_list(options, "or", lang=self.lang) + "?" - self.speak(opt_str, wait=True) - - resp = self.get_response(dialog=dialog, data=data) - - if resp: - match, score = match_one(resp, options) - if score < min_conf: - if self.voc_match(resp, 'last'): - resp = options[-1] - else: - num = extract_number(resp, ordinals=True, lang=self.lang) - resp = None - if num and num <= len(options): - resp = options[num - 1] - else: - resp = match - return resp - - # method not present in mycroft-core - def _voc_list(self, voc_filename: str, - lang: Optional[str] = None) -> List[str]: - """ - Get list of vocab options for the requested resource and cache the - results for future references. - @param voc_filename: Name of vocab resource to get options for - @param lang: language to get vocab for (default self.lang) - @return: list of string vocab options - """ - lang = lang or self.lang - cache_key = lang + voc_filename - - if cache_key not in self._voc_cache: - vocab = self._resources.load_vocabulary_file(voc_filename) or \ - CoreResources(lang).load_vocabulary_file(voc_filename) - if vocab: - self._voc_cache[cache_key] = list(chain(*vocab)) - - return self._voc_cache.get(cache_key) or [] - - def voc_match(self, utt: str, voc_filename: str, lang: Optional[str] = None, - exact: bool = False): - """ - Determine if the given utterance contains the vocabulary provided. - - By default the method checks if the utterance contains the given vocab - thereby allowing the user to say things like "yes, please" and still - match against "Yes.voc" containing only "yes". An exact match can be - requested. - - The method first checks in the current Skill's .voc files and secondly - in the "res/text" folder of mycroft-core. The result is cached to - avoid hitting the disk each time the method is called. - - Args: - utt (str): Utterance to be tested - voc_filename (str): Name of vocabulary file (e.g. 'yes' for - 'res/text/en-us/yes.voc') - lang (str): Language code, defaults to self.lang - exact (bool): Whether the vocab must exactly match the utterance - - Returns: - bool: True if the utterance has the given vocabulary it - """ - match = False - _vocs = self._voc_list(voc_filename, lang) - - if utt and _vocs: - if exact: - # Check for exact match - match = any(i.strip() == utt - for i in _vocs) - else: - # Check for matches against complete words - match = any([re.match(r'.*\b' + i + r'\b.*', utt) - for i in _vocs]) - - return match - - def report_metric(self, name: str, data: dict): - """ - Report a skill metric to the Mycroft servers. - - Args: - name (str): Name of metric. Must use only letters and hyphens - data (dict): JSON dictionary to report. Must be valid JSON - """ - try: - if Configuration().get('opt_in', False): - MetricsApi().report_metric(name, data) - except Exception as e: - self.log.error(f'Metric couldn\'t be uploaded, due to a network error ({e})') - - def send_email(self, title: str, body: str): - """ - Send an email to the registered user's email. - - Args: - title (str): Title of email - body (str): HTML body of email. This supports - simple HTML like bold and italics - """ - EmailApi().send_email(title, body, self.skill_id) - - def _handle_collect_resting(self, message: Optional[Message] = None): - """ - Handler for collect resting screen messages. - - Sends info on how to trigger this skill's resting page. - """ - self.log.info('Registering resting screen') - msg = message or Message("") - message = msg.reply( - 'mycroft.mark2.register_idle', - data={'name': self.resting_name, 'id': self.skill_id}, - context={"skill_id": self.skill_id} - ) - self.bus.emit(message) - - def register_resting_screen(self): - """ - Registers resting screen from the resting_screen_handler decorator. - - This only allows one screen and if two is registered only one - will be used. - """ - for attr_name in get_non_properties(self): - method = getattr(self, attr_name) - if hasattr(method, 'resting_handler'): - self.resting_name = method.resting_handler - self.log.info(f'Registering resting screen {method} for {self.resting_name}.') - - # Register for handling resting screen - self.add_event(f'{self.skill_id}.idle', method, speak_errors=False) - # Register handler for resting screen collect message - self.add_event('mycroft.mark2.collect_idle', - self._handle_collect_resting, speak_errors=False) - - # Do a send at load to make sure the skill is registered - # if reloaded - self._handle_collect_resting() - break - - def _register_decorated(self): - """ - Register all intent handlers that are decorated with an intent. - - Looks for all functions that have been marked by a decorator - and read the intent data from them. The intent handlers aren't the - only decorators used. Skip properties as calling getattr on them - executes the code which may have unintended side effects - """ - for attr_name in get_non_properties(self): - method = getattr(self, attr_name) - if hasattr(method, 'intents'): - for intent in getattr(method, 'intents'): - self.register_intent(intent, method) - - if hasattr(method, 'intent_files'): - for intent_file in getattr(method, 'intent_files'): - self.register_intent_file(intent_file, method) - - def find_resource(self, res_name: str, res_dirname: Optional[str] = None, - lang: Optional[str] = None): - """ - Find a resource file. - - Searches for the given filename using this scheme: - 1. Search the resource lang directory: - /// - 2. Search the resource directory: - // - - 3. Search the locale lang directory or other subdirectory: - /locale// or - /locale//.../ - - Args: - res_name (string): The resource name to be found - res_dirname (string, optional): A skill resource directory, such - 'dialog', 'vocab', 'regex' or 'ui'. - Defaults to None. - lang (string, optional): language folder to be used. - Defaults to self.lang. - - Returns: - string: The full path to the resource file or None if not found - """ - lang = lang or self.lang - x = find_resource(res_name, self.res_dir, res_dirname, lang) - if x: - return str(x) - self.log.error(f"Skill {self.skill_id} resource '{res_name}' for lang " - f"'{lang}' not found in skill") - - # method not present in mycroft-core - def _on_event_start(self, message: Message, handler_info: str, - skill_data: dict): - """ - Indicate that the skill handler is starting. - """ - if handler_info: - # Indicate that the skill handler is starting if requested - msg_type = handler_info + '.start' - message.context["skill_id"] = self.skill_id - self.bus.emit(message.forward(msg_type, skill_data)) - - # method not present in mycroft-core - def _on_event_end(self, message: Message, handler_info: str, - skill_data: dict): - """ - Store settings (if changed) and indicate that the skill handler has - completed. - """ - if self.settings != self._initial_settings: - self.settings.store() - self._initial_settings = copy(self.settings) - if handler_info: - msg_type = handler_info + '.complete' - message.context["skill_id"] = self.skill_id - self.bus.emit(message.forward(msg_type, skill_data)) - - # method not present in mycroft-core - def _on_event_error(self, error: str, message: Message, handler_info: str, - skill_data: dict, speak_errors: bool): - """Speak and log the error.""" - # Convert "MyFancySkill" to "My Fancy Skill" for speaking - handler_name = camel_case_split(self.name) - msg_data = {'skill': handler_name} - speech = get_dialog('skill.error', self.lang, msg_data) - if speak_errors: - self.speak(speech) - self.log.exception(error) - # append exception information in message - skill_data['exception'] = repr(error) - if handler_info: - # Indicate that the skill handler errored - msg_type = handler_info + '.error' - message = message or Message("") - message.context["skill_id"] = self.skill_id - self.bus.emit(message.forward(msg_type, skill_data)) - - def add_event(self, name: str, handler: callable, - handler_info: Optional[str] = None, once: bool = False, - speak_errors: bool = True): - """ - Create event handler for executing intent or other event. - - Args: - name (string): IntentParser name - handler (func): Method to call - handler_info (string): Base message when reporting skill event - handler status on messagebus. - once (bool, optional): Event handler will be removed after it has - been run once. - speak_errors (bool, optional): Determines if an error dialog should be - spoken to inform the user whenever - an exception happens inside the handler - """ - skill_data = {'name': get_handler_name(handler)} - - def on_error(error, message): - if isinstance(error, AbortEvent): - self.log.info("Skill execution aborted") - self._on_event_end(message, handler_info, skill_data) - return - self._on_event_error(error, message, handler_info, skill_data, - speak_errors) - - def on_start(message): - self._on_event_start(message, handler_info, skill_data) - - def on_end(message): - self._on_event_end(message, handler_info, skill_data) - - wrapper = create_wrapper(handler, self.skill_id, on_start, on_end, - on_error) - return self.events.add(name, wrapper, once) - - def remove_event(self, name: str) -> bool: - """ - Removes an event from bus emitter and events list. - - Args: - name (string): Name of Intent or Scheduler Event - Returns: - bool: True if found and removed, False if not found - """ - return self.events.remove(name) - - def _register_adapt_intent(self, - intent_parser: Union[IntentBuilder, Intent, str], - handler: callable): - """ - Register an adapt intent. - - Args: - intent_parser: Intent object to parse utterance for the handler. - handler (func): function to register with intent - """ - # Default to the handler's function name if none given - is_anonymous = not intent_parser.name - name = intent_parser.name or handler.__name__ - if is_anonymous: - # Find a good name - original_name = name - nbr = 0 - while name in self.intent_service.intent_names: - nbr += 1 - name = f'{original_name}{nbr}' - elif name in self.intent_service.intent_names and \ - not self.intent_service.intent_is_detached(name): - raise ValueError(f'The intent name {name} is already taken') - - munge_intent_parser(intent_parser, name, self.skill_id) - self.intent_service.register_adapt_intent(name, intent_parser) - if handler: - self.add_event(intent_parser.name, handler, - 'mycroft.skill.handler') - - def register_intent(self, intent_parser: Union[IntentBuilder, Intent, str], - handler: callable): - """ - Register an Intent with the intent service. - - Args: - intent_parser: Intent, IntentBuilder object or padatious intent - file to parse utterance for the handler. - handler (func): function to register with intent - """ - if isinstance(intent_parser, IntentBuilder): - intent_parser = intent_parser.build() - if (isinstance(intent_parser, str) and - intent_parser.endswith('.intent')): - return self.register_intent_file(intent_parser, handler) - elif not isinstance(intent_parser, Intent): - raise ValueError('"' + str(intent_parser) + '" is not an Intent') - - return self._register_adapt_intent(intent_parser, handler) - - def register_intent_file(self, intent_file: str, handler: callable): - """Register an Intent file with the intent service. - - For example: - food.order.intent: - Order some {food}. - Order some {food} from {place}. - I'm hungry. - Grab some {food} from {place}. - - Optionally, you can also use - to specify some examples of {food} and {place} - - In addition, instead of writing out multiple variations - of the same sentence you can write: - food.order.intent: - (Order | Grab) some {food} (from {place} | ). - I'm hungry. - - Args: - intent_file: name of file that contains example queries - that should activate the intent. Must end with - '.intent' - handler: function to register with intent - """ - for lang in self._native_langs: - name = f'{self.skill_id}:{intent_file}' - resources = self._load_lang(self.res_dir, lang) - resource_file = ResourceFile(resources.types.intent, intent_file) - if resource_file.file_path is None: - self.log.error(f'Unable to find "{intent_file}"') - continue - filename = str(resource_file.file_path) - self.intent_service.register_padatious_intent(name, filename, lang) - if handler: - self.add_event(name, handler, 'mycroft.skill.handler') - - def register_entity_file(self, entity_file: str): - """ - Register an Entity file with the intent service. - - An Entity file lists the exact values that an entity can hold. - For example: - ask.day.intent: - Is it {weekend}? - weekend.entity: - Saturday - Sunday - - Args: - entity_file (string): name of file that contains examples of an - entity. - """ - if entity_file.endswith('.entity'): - entity_file = entity_file.replace('.entity', '') - for lang in self._native_langs: - resources = self._load_lang(self.res_dir, lang) - entity = ResourceFile(resources.types.entity, entity_file) - if entity.file_path is None: - self.log.error(f'Unable to find "{entity_file}"') - continue - filename = str(entity.file_path) - name = f"{self.skill_id}:{basename(entity_file)}_" \ - f"{md5(entity_file.encode('utf-8')).hexdigest()}" - self.intent_service.register_padatious_entity(name, filename, lang) - - def handle_enable_intent(self, message: Message): - """ - Listener to enable a registered intent if it belongs to this skill. - @param message: `mycroft.skill.enable_intent` Message - """ - intent_name = message.data['intent_name'] - for (name, _) in self.intent_service.detached_intents: - if name == intent_name: - return self.enable_intent(intent_name) - - def handle_disable_intent(self, message: Message): - """ - Listener to disable a registered intent if it belongs to this skill. - @param message: `mycroft.skill.disable_intent` Message - """ - intent_name = message.data['intent_name'] - for (name, _) in self.intent_service.registered_intents: - if name == intent_name: - return self.disable_intent(intent_name) - - def disable_intent(self, intent_name: str) -> bool: - """ - Disable a registered intent if it belongs to this skill. - - Args: - intent_name (string): name of the intent to be disabled - - Returns: - bool: True if disabled, False if it wasn't registered - """ - if intent_name in self.intent_service: - self.log.info('Disabling intent ' + intent_name) - name = f'{self.skill_id}:{intent_name}' - self.intent_service.detach_intent(name) - - langs = [self._core_lang] + self._secondary_langs - for lang in langs: - lang_intent_name = f'{name}_{lang}' - self.intent_service.detach_intent(lang_intent_name) - return True - else: - self.log.error(f'Could not disable {intent_name}, it hasn\'t been registered.') - return False - - def enable_intent(self, intent_name: str) -> bool: - """ - (Re)Enable a registered intent if it belongs to this skill. - - Args: - intent_name: name of the intent to be enabled - - Returns: - bool: True if enabled, False if it wasn't registered - """ - intent = self.intent_service.get_intent(intent_name) - if intent: - if ".intent" in intent_name: - self.register_intent_file(intent_name, None) - else: - intent.name = intent_name - self.register_intent(intent, None) - self.log.debug(f'Enabling intent {intent_name}') - return True - else: - self.log.error(f'Could not enable {intent_name}, it hasn\'t been registered.') - return False - - def set_context(self, context: str, word: str = '', origin: str = ''): - """ - Add context to intent service - - Args: - context: Keyword - word: word connected to keyword - origin: origin of context - """ - if not isinstance(context, str): - raise ValueError('Context should be a string') - if not isinstance(word, str): - raise ValueError('Word should be a string') - - context = self._alphanumeric_skill_id + context - self.intent_service.set_adapt_context(context, word, origin) - - def remove_context(self, context: str): - """ - Remove a keyword from the context manager. - """ - if not isinstance(context, str): - raise ValueError('context should be a string') - context = self._alphanumeric_skill_id + context - self.intent_service.remove_adapt_context(context) - - def handle_set_cross_context(self, message: Message): - """ - Add global context to the intent service. - @param message: `mycroft.skill.set_cross_context` Message - """ - context = message.data.get('context') - word = message.data.get('word') - origin = message.data.get('origin') - - self.set_context(context, word, origin) - - def handle_remove_cross_context(self, message: Message): - """ - Remove global context from the intent service. - @param message: `mycroft.skill.remove_cross_context` Message - """ - context = message.data.get('context') - self.remove_context(context) - - def set_cross_skill_context(self, context: str, word: str = ''): - """ - Tell all skills to add a context to the intent service - - Args: - context: Keyword - word: word connected to keyword - """ - msg = dig_for_message() or Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward('mycroft.skill.set_cross_context', - {'context': context, 'word': word, - 'origin': self.skill_id})) - - def remove_cross_skill_context(self, context: str): - """ - Tell all skills to remove a keyword from the context manager. - """ - if not isinstance(context, str): - raise ValueError('context should be a string') - msg = dig_for_message() or Message("") - if "skill_id" not in msg.context: - msg.context["skill_id"] = self.skill_id - self.bus.emit(msg.forward('mycroft.skill.remove_cross_context', - {'context': context})) - - def register_vocabulary(self, entity: str, entity_type: str, - lang: Optional[str] = None): - """ - Register a word to a keyword - @param entity: word to register - @param entity_type: Intent handler entity name to associate entity to - @param lang: language of `entity` (default self.lang) - """ - keyword_type = self._alphanumeric_skill_id + entity_type - lang = lang or self.lang - self.intent_service.register_adapt_keyword(keyword_type, entity, - lang=lang) - - def register_regex(self, regex_str: str, lang: Optional[str] = None): - """ - Register a new regex. - @param regex_str: Regex string to add - @param lang: language of regex_str (default self.lang) - """ - self.log.debug('registering regex string: ' + regex_str) - regex = munge_regex(regex_str, self.skill_id) - re.compile(regex) # validate regex - self.intent_service.register_adapt_regex(regex, lang=lang or self.lang) - - def speak(self, utterance: str, expect_response: bool = False, - wait: bool = False, meta: Optional[dict] = None): - """Speak a sentence. - - Args: - utterance (str): sentence mycroft should speak - expect_response (bool): set to True if Mycroft should listen - for a response immediately after - speaking the utterance. - wait (bool): set to True to block while the text - is being spoken. - meta: Information of what built the sentence. - """ - # registers the skill as being active - meta = meta or {} - meta['skill'] = self.skill_id - self.enclosure.register(self.skill_id) - data = {'utterance': utterance, - 'expect_response': expect_response, - 'meta': meta, - 'lang': self.lang} - - # grab message that triggered speech so we can keep context - message = dig_for_message() - m = message.forward("speak", data) if message \ - else Message("speak", data) - m.context["skill_id"] = self.skill_id - - # update any auto-translation metadata in message.context - if "translation_data" in meta: - tx_data = merge_dict(m.context.get("translation_data", {}), - meta["translation_data"]) - m.context["translation_data"] = tx_data - - self.bus.emit(m) - - if wait: - sessid = SessionManager.get(m).session_id - event = Event() - - def handle_output_end(msg): - sess = SessionManager.get(msg) - if sessid == sess.session_id: - event.set() - - self.bus.on("recognizer_loop:audio_output_end", handle_output_end) - event.wait(timeout=15) - self.bus.remove("recognizer_loop:audio_output_end", - handle_output_end) - - def speak_dialog(self, key: str, data: Optional[dict] = None, - expect_response: bool = False, wait: bool = False): - """ - Speak a random sentence from a dialog file. - - Args: - key (str): dialog file key (e.g. "hello" to speak from the file - "locale/en-us/hello.dialog") - data (dict): information used to populate sentence - expect_response (bool): set to True if Mycroft should listen - for a response immediately after - speaking the utterance. - wait (bool): set to True to block while the text - is being spoken. - """ - if self.dialog_renderer: - data = data or {} - self.speak( - self.dialog_renderer.render(key, data), - expect_response, wait, meta={'dialog': key, 'data': data} - ) - else: - self.log.warning( - 'dialog_render is None, does the locale/dialog folder exist?' - ) - self.speak(key, expect_response, wait, {}) - - # method named init_dialog in mycroft-core - def load_dialog_files(self, root_directory: Optional[str] = None): - """ - Load dialog files for all configured languages - @param root_directory: Directory to locate resources in - (default self.res_dir) - """ - root_directory = root_directory or self.res_dir - # If "/dialog/" exists, load from there. Otherwise, - # load dialog from "/locale/" - for lang in self._native_langs: - resources = self._load_lang(root_directory, lang) - if resources.types.dialog.base_directory is None: - self.log.debug(f'No dialog loaded for {lang}') - - def load_data_files(self, root_directory: Optional[str] = None): - """ - Called by the skill loader to load intents, dialogs, etc. - - Args: - root_directory (str): root folder to use when loading files. - """ - root_directory = root_directory or self.res_dir - self.load_dialog_files(root_directory) - self.load_vocab_files(root_directory) - self.load_regex_files(root_directory) - - def load_vocab_files(self, root_directory: Optional[str] = None): - """ Load vocab files found under skill's root directory.""" - root_directory = root_directory or self.res_dir - for lang in self._native_langs: - resources = self._load_lang(root_directory, lang) - if resources.types.vocabulary.base_directory is None: - self.log.debug(f'No vocab loaded for {lang}') - else: - skill_vocabulary = resources.load_skill_vocabulary( - self._alphanumeric_skill_id - ) - # For each found intent register the default along with any aliases - for vocab_type in skill_vocabulary: - for line in skill_vocabulary[vocab_type]: - entity = line[0] - aliases = line[1:] - self.intent_service.register_adapt_keyword( - vocab_type, entity, aliases, lang) - - def load_regex_files(self, root_directory=None): - """ Load regex files found under the skill directory.""" - root_directory = root_directory or self.res_dir - for lang in self._native_langs: - resources = self._load_lang(root_directory, lang) - if resources.types.regex.base_directory is not None: - regexes = resources.load_skill_regex(self._alphanumeric_skill_id) - for regex in regexes: - self.intent_service.register_adapt_regex(regex, lang) - - def __handle_stop(self, message): - """Handler for the "mycroft.stop" signal. Runs the user defined - `stop()` method. - """ - message.context['skill_id'] = self.skill_id - self.bus.emit(message.forward(self.skill_id + ".stop")) - try: - if self.stop(): - self.bus.emit(message.reply("mycroft.stop.handled", - {"by": "skill:" + self.skill_id}, - {"skill_id": self.skill_id})) - except Exception as e: - self.log.exception(f'Failed to stop skill: {self.skill_id}: {e}') - - def stop(self): - """ - Optional method implemented by subclass. Called when system or user - requests `stop` to cancel current execution. - """ - pass - - def shutdown(self): - """ - Optional shutdown procedure implemented by subclass. - - This method is intended to be called during the skill process - termination. The skill implementation must shut down all processes and - operations in execution. - """ - pass - - def default_shutdown(self): - """ - Parent function called internally to shut down everything. - 1) Call skill.stop() to allow skill to clean up any active processes - 2) Store skill settings and remove file watchers - 3) Shutdown skill GUI to clear any active pages - 4) Shutdown the event_scheduler and remove any pending events - 5) Call skill.shutdown() to allow skill to do any other shutdown tasks - 6) Emit `detach_skill` Message to notify skill is shut down - """ - - try: - # Allow skill to handle `stop` actions before shutting things down - self.stop() - except Exception as e: - self.log.error(f'Failed to stop skill: {self.skill_id}: {e}', - exc_info=True) - - try: - self.settings_change_callback = None - - # Store settings - if self.settings != self._initial_settings: - self.settings.store() - if self._settings_meta: - self._settings_meta.stop() - if self._settings_watchdog: - self._settings_watchdog.shutdown() - except Exception as e: - self.log.error(f"Failed to store settings for {self.skill_id}: {e}") - - try: - # Clear skill from gui - if self.gui: - self.gui.shutdown() - except Exception as e: - self.log.error(f"Failed to shutdown gui for {self.skill_id}: {e}") - - try: - # removing events - if self.event_scheduler: - self.event_scheduler.shutdown() - self.events.clear() - except Exception as e: - self.log.error(f"Failed to remove events for {self.skill_id}: {e}") - - try: - self.shutdown() - except Exception as e: - self.log.error(f'Skill specific shutdown function encountered an ' - f'error: {e}') - - self.bus.emit( - Message('detach_skill', {'skill_id': f"{self.skill_id}:"}, - {"skill_id": self.skill_id})) - - def schedule_event(self, handler: callable, - when: Union[int, float, datetime.datetime], - data: Optional[dict] = None, name: Optional[str] = None, - context: Optional[dict] = None): - """ - Schedule a single-shot event. - - Args: - handler: method to be called - when (datetime/int/float): datetime (in system timezone) or - number of seconds in the future when the - handler should be called - data (dict, optional): data to send when the handler is called - name (str, optional): reference name - NOTE: This will not warn or replace a - previously scheduled event of the same - name. - context (dict, optional): context (dict, optional): message - context to send when the handler - is called - """ - message = dig_for_message() - context = context or message.context if message else {} - context["skill_id"] = self.skill_id - return self.event_scheduler.schedule_event(handler, when, data, name, - context=context) - - def schedule_repeating_event(self, handler: callable, - when: Union[int, float, datetime.datetime], - frequency: Union[int, float], - data: Optional[dict] = None, - name: Optional[str] = None, - context: Optional[dict] = None): - """ - Schedule a repeating event. - - Args: - handler: method to be called - when (datetime): time (in system timezone) for first - calling the handler, or None to - initially trigger seconds - from now - frequency (float/int): time in seconds between calls - data (dict, optional): data to send when the handler is called - name (str, optional): reference name, must be unique - context (dict, optional): context (dict, optional): message - context to send when the handler - is called - """ - message = dig_for_message() - context = context or message.context if message else {} - context["skill_id"] = self.skill_id - self.event_scheduler.schedule_repeating_event(handler, when, frequency, - data, name, - context=context) - - def update_scheduled_event(self, name: str, data: Optional[dict] = None): - """ - Change data of event. - - Args: - name (str): reference name of event (from original scheduling) - data (dict): event data - """ - self.event_scheduler.update_scheduled_event(name, data) - - def cancel_scheduled_event(self, name: str): - """ - Cancel a pending event. The event will no longer be scheduled - to be executed - - Args: - name (str): reference name of event (from original scheduling) - """ - self.event_scheduler.cancel_scheduled_event(name) - - def get_scheduled_event_status(self, name: str) -> int: - """Get scheduled event data and return the amount of time left - - Args: - name (str): reference name of event (from original scheduling) - - Returns: - int: the time left in seconds - - Raises: - Exception: Raised if event is not found - """ - return self.event_scheduler.get_scheduled_event_status(name) - - def cancel_all_repeating_events(self): - """ - Cancel any repeating events started by the skill. - """ - self.event_scheduler.cancel_all_repeating_events() - - -class SkillGUI(GUIInterface): - def __init__(self, skill: BaseSkill): - """ - Wraps `GUIInterface` for use with a skill. - """ - self._skill = skill - skill_id = skill.skill_id - bus = skill.bus - config = skill.config_core.get('gui') - ui_directories = get_ui_directories(skill.root_dir) - GUIInterface.__init__(self, skill_id=skill_id, bus=bus, config=config, - ui_directories=ui_directories) +BaseSkill = OVOSSkill # backwards compat - @property - @deprecated("`skill` should not be referenced directly", "0.1.0") - def skill(self): - return self._skill diff --git a/ovos_workshop/skills/common_play.py b/ovos_workshop/skills/common_play.py index 884569c2..8d063cd4 100644 --- a/ovos_workshop/skills/common_play.py +++ b/ovos_workshop/skills/common_play.py @@ -1,6 +1,6 @@ from inspect import signature from threading import Event -from ovos_workshop.skills.ovos import OVOSSkill, MycroftSkill +from ovos_workshop.skills.ovos import OVOSSkill from ovos_bus_client import Message from ovos_utils.log import LOG @@ -29,7 +29,7 @@ def check_class(cls): d = cls.__dict__ np = [k for k in d if not isinstance(d[k], property)] # Recurse through base classes excluding MycroftSkill and object - for b in [b for b in cls.__bases__ if b not in (object, MycroftSkill)]: + for b in [b for b in cls.__bases__ if b not in (object, OVOSSkill)]: np += check_class(b) return np diff --git a/ovos_workshop/skills/mycroft_skill.py b/ovos_workshop/skills/mycroft_skill.py index 07e7abc0..d60caeb1 100644 --- a/ovos_workshop/skills/mycroft_skill.py +++ b/ovos_workshop/skills/mycroft_skill.py @@ -13,17 +13,15 @@ # limitations under the License. import shutil -from abc import ABCMeta from os.path import join, exists -from typing import Optional from ovos_bus_client import MessageBusClient, Message from ovos_utils.log import LOG, log_deprecation, deprecated from ovos_workshop.decorators.compat import backwards_compat -from ovos_workshop.skills.base import BaseSkill, is_classic_core +from ovos_workshop.skills.ovos import OVOSSkill, is_classic_core, _OVOSSkillMetaclass -class _SkillMetaclass(ABCMeta): +class _SkillMetaclass(_OVOSSkillMetaclass): """ This metaclass ensures we can load skills like regular python objects. mycroft-core required a skill loader helper class, which created the skill @@ -36,9 +34,26 @@ class _SkillMetaclass(ABCMeta): To override isinstance checks we also need to use a metaclass - TODO: remove compat ovos-core 0.2.0, including MycroftSkill class + TODO: remove compat ovos-core 0.2.0 at the latest, including MycroftSkill class """ + def __instancecheck_classic__(self, instance): + # instance imported from vanilla mycroft + from mycroft.skills import MycroftSkill as _CoreSkill + from ovos_workshop.app import OVOSAbstractApplication + if issubclass(instance.__class__, _CoreSkill): + return True + return issubclass(instance.__class__, OVOSSkill) and \ + not issubclass(instance.__class__, OVOSAbstractApplication) + + @backwards_compat(classic_core=__instancecheck_classic__) + def __instancecheck__(self, instance): + from ovos_workshop.app import OVOSAbstractApplication + if issubclass(instance.__class__, OVOSAbstractApplication): + return False + return super().__instancecheck__(instance) or \ + issubclass(instance.__class__, OVOSSkill) + def __call__(cls, *args, **kwargs): from ovos_bus_client import MessageBusClient from ovos_utils.messagebus import FakeBus @@ -109,21 +124,8 @@ def __call__(cls, *args, **kwargs): skill._startup(bus, skill_id) return skill - def __instancecheck_classic__(self, instance): - # instance imported from vanilla mycroft - from mycroft.skills import MycroftSkill as _CoreSkill - if issubclass(instance.__class__, _CoreSkill): - return True - return issubclass(instance.__class__, MycroftSkill) - - @backwards_compat(classic_core=__instancecheck_classic__) - def __instancecheck__(self, instance): - from ovos_workshop.skills.ovos import OVOSSkill - return super().__instancecheck__(instance) or \ - issubclass(instance.__class__, OVOSSkill) - -class MycroftSkill(BaseSkill, metaclass=_SkillMetaclass): +class MycroftSkill(OVOSSkill, metaclass=_SkillMetaclass): """ Base class for mycroft skills providing common behaviour and parameters to all Skill implementations. This class is kept for backwards-compat. It is @@ -188,13 +190,13 @@ def _init_settings_manager(self): def __init_settings_classic(self): # migrate settings if needed - if not exists(self._settings_path) and \ + if not exists(self.settings_path) and \ exists(self._old_settings_path): LOG.warning("Found skill settings at pre-xdg location, " "migrating!") - shutil.copy(self._old_settings_path, self._settings_path) + shutil.copy(self._old_settings_path, self.settings_path) LOG.info(f"{self._old_settings_path} moved to " - f"{self._settings_path}") + f"{self.settings_path}") super()._init_settings() @backwards_compat(classic_core=__init_settings_classic) @@ -202,27 +204,6 @@ def _init_settings(self): """Setup skill settings.""" super()._init_settings() - # renamed in base class for naming consistency - def init_dialog(self, root_directory: Optional[str] = None): - """ - DEPRECATED: use load_dialog_files instead - """ - log_deprecation("Use `load_dialog_files`", "0.1.0") - self.load_dialog_files(root_directory) - - # renamed in base class for naming consistency - def make_active(self): - """ - Bump skill to active_skill list in intent_service. - - This enables converse method to be called even without skill being - used in last 5 minutes. - - deprecated: use self._activate() instead - """ - log_deprecation("Use `_activate`", "0.1.0") - self._activate() - # patched due to functional (internal) differences under mycroft-core def __on_end_classic(self, message: Message, handler_info: str, skill_data: dict): @@ -247,47 +228,6 @@ def _on_event_end(self, message: Message, handler_info: str, """ return super()._on_event_end(message, handler_info, skill_data) - # renamed in base class for naming consistency - # refactored to use new resource utils - def translate(self, text: str, data: Optional[dict] = None): - """ - Deprecated method for translating a dialog file. - use self._resources.render_dialog(text, data) instead - """ - log_deprecation("Use `_resources.render_dialog`", "0.1.0") - return self._resources.render_dialog(text, data) - - # renamed in base class for naming consistency - # refactored to use new resource utils - def translate_namedvalues(self, name: str, delim: str = ','): - """ - Deprecated method for translating a name/value file. - use self._resources.load_named_value_filetext, data) instead - """ - log_deprecation("Use `_resources.load_named_value_file`", "0.1.0") - return self._resources.load_named_value_file(name, delim) - - # renamed in base class for naming consistency - # refactored to use new resource utils - def translate_list(self, list_name: str, data: Optional[dict] = None): - """ - Deprecated method for translating a list. - use delf._resources.load_list_file(text, data) instead - """ - log_deprecation("Use `_resources.load_list_file`", "0.1.0") - return self._resources.load_list_file(list_name, data) - - # renamed in base class for naming consistency - # refactored to use new resource utils - def translate_template(self, template_name: str, - data: Optional[dict] = None): - """ - Deprecated method for translating a template file - use delf._resources.template_file(text, data) instead - """ - log_deprecation("Use `_resources.template_file`", "0.1.0") - return self._resources.load_template_file(template_name, data) - # refactored - backwards compat + log warnings @property def settings_meta(self): @@ -316,9 +256,9 @@ def __get_settings_pclassic(self): log_deprecation("`self.settings_write_path` is no longer used", "0.1.0") return join(self.settings_write_path, 'settings.json') - return super()._settings_path + return super().settings_path @property @backwards_compat(classic_core=__get_settings_pclassic) - def _settings_path(self): - return super()._settings_path + def settings_path(self): + return super().settings_path diff --git a/ovos_workshop/skills/ovos.py b/ovos_workshop/skills/ovos.py index 7dca9a00..a971a632 100644 --- a/ovos_workshop/skills/ovos.py +++ b/ovos_workshop/skills/ovos.py @@ -1,21 +1,81 @@ +import datetime import re +import sys +import time +import traceback from abc import ABCMeta -from threading import Event -from typing import List, Optional, Union +from copy import copy +from hashlib import md5 +from inspect import signature +from itertools import chain +from os.path import join, abspath, dirname, basename, isfile +from threading import Event, RLock +from typing import Dict, Callable, List, Optional, Union +from json_database import JsonStorage +from lingua_franca.format import pronounce_number, join_list +from lingua_franca.parse import yes_or_no, extract_number +from ovos_config.config import Configuration +from ovos_config.locations import get_xdg_config_save_path + +from ovos_backend_client.api import EmailApi, MetricsApi from ovos_bus_client import MessageBusClient from ovos_bus_client.message import Message, dig_for_message -from ovos_utils.intents import IntentBuilder, Intent -from ovos_utils.log import LOG, log_deprecation +from ovos_bus_client.session import SessionManager +from ovos_utils import camel_case_split +from ovos_utils import classproperty +from ovos_utils.dialog import get_dialog, MustacheDialogRenderer +from ovos_utils.enclosure.api import EnclosureAPI +from ovos_utils.events import EventContainer, EventSchedulerInterface +from ovos_utils.file_utils import FileWatcher +from ovos_utils.gui import GUIInterface, get_ui_directories +from ovos_utils.intents import ConverseTracker, IntentBuilder, Intent +from ovos_utils.intents.intent_service_interface import munge_regex, \ + munge_intent_parser, IntentServiceInterface +from ovos_utils.json_helper import merge_dict +from ovos_utils.log import LOG, log_deprecation, deprecated +from ovos_utils.messagebus import get_handler_name, create_wrapper, \ + get_message_lang +from ovos_utils.parse import match_one +from ovos_utils.process_utils import RuntimeRequirements from ovos_utils.skills import get_non_properties from ovos_utils.skills.audioservice import OCPInterface from ovos_utils.skills.settings import PrivateSettings from ovos_utils.sound import play_audio from ovos_workshop.decorators.compat import backwards_compat +from ovos_workshop.decorators.killable import AbortEvent, killable_event, \ + AbortQuestion from ovos_workshop.decorators.layers import IntentLayers -from ovos_workshop.resource_files import SkillResources -from ovos_workshop.skills.base import BaseSkill -from ovos_workshop.skills.mycroft_skill import MycroftSkill +from ovos_workshop.filesystem import FileSystemAccess +from ovos_workshop.resource_files import ResourceFile, \ + CoreResources, find_resource, SkillResources +from ovos_workshop.settings import SkillSettingsManager + + +def is_classic_core(): + try: + from mycroft.version import OVOS_VERSION_STR + return False + except: + try: + import mycroft + return True + except: + return False + + +def simple_trace(stack_trace: List[str]) -> str: + """ + Generate a simplified traceback. + @param stack_trace: Formatted stack trace (each string ends with \n) + @return: Stack trace with any empty lines removed and last line removed + """ + stack_trace = stack_trace[:-1] + tb = 'Traceback:\n' + for line in stack_trace: + if line.strip(): + tb += line + return tb class _OVOSSkillMetaclass(ABCMeta): @@ -28,41 +88,228 @@ def __instancecheck_classic__(self, instance): from mycroft.skills import MycroftSkill as _CoreSkill if issubclass(instance.__class__, _CoreSkill): return True - return issubclass(instance.__class__, MycroftSkill) + return issubclass(instance.__class__, OVOSSkill) @backwards_compat(classic_core=__instancecheck_classic__) def __instancecheck__(self, instance): - return super().__instancecheck__(instance) or \ - issubclass(instance.__class__, MycroftSkill) + return super().__instancecheck__(instance) -class OVOSSkill(BaseSkill, metaclass=_OVOSSkillMetaclass): +class OVOSSkill(metaclass=_OVOSSkillMetaclass): """ - New features: - - all patches for MycroftSkill class - - self.private_settings - - killable intents - - IntentLayers + Base class for mycroft skills providing common behaviour and parameters + to all Skill implementations. This base class does not require `mycroft` to + be importable + + skill_launcher.py used to be skill_loader-py in mycroft-core + + for launching skills one can use skill_launcher.py to run them standalone + (eg, docker), but the main objective is to make skills work more like proper + python objects and allow usage of the class directly + + the considerations are: + + - most skills in the wild don't expose kwargs, so don't accept + skill_id or bus + - most skills expect a loader class to set up the bus and skill_id after + object creation + - skills can not do pythonic things in init, instead of doing things after + super() devs are expected to use initialize() which is a mycroft invention + and non-standard + - main concern is that anything depending on self.skill_id being set can not + be used in init method (eg. self.settings and self.file_system) + - __new__ uncouples the skill init from a helper class, making skills work + like regular python objects + - the magic in `__new__` is just so we don't break everything in the wild, + since we cant start requiring skill_id and bus args + + KwArgs: + name (str): skill name - DEPRECATED + skill_id (str): unique skill identifier + bus (MycroftWebsocketClient): Optional bus connection """ - def __init__(self, *args, **kwargs): - # note - define these before super() because of self.bind() + def __init__(self, name: Optional[str] = None, + bus: Optional[MessageBusClient] = None, + resources_dir: Optional[str] = None, + settings: Optional[JsonStorage] = None, + gui: Optional[GUIInterface] = None, + enable_settings_manager: bool = True, + skill_id: str = ""): + """ + Create an OVOSSkill object. + @param name: DEPRECATED skill_name + @param bus: MessageBusClient to bind to skill + @param resources_dir: optional root resource directory (else defaults to + skill `root_dir` + @param settings: Optional settings object, else defined in skill config + path + @param gui: Optional SkillGUI, else one is initialized + @param enable_settings_manager: if True, enables a SettingsManager for + this skill to manage default settings and backend sync + @param skill_id: Unique ID for this skill + """ + + self.log = LOG # a dedicated namespace will be assigned in _startup + self._enable_settings_manager = enable_settings_manager + self._init_event = Event() + self.name = name or self.__class__.__name__ + self.resting_name = None + self.skill_id = skill_id # set by SkillLoader, guaranteed unique + self._settings_meta = None # DEPRECATED - backwards compat only + self.settings_manager = None self.private_settings = None - self._threads = [] - self._original_converse = self.converse - self.intent_layers = IntentLayers() + + # Get directory of skill source (__init__.py) + self.root_dir = dirname(abspath(sys.modules[self.__module__].__file__)) + self.res_dir = resources_dir or self.root_dir + + self.gui = gui + self._bus = bus + self._enclosure = EnclosureAPI() + + # Core configuration + self.config_core: Configuration = Configuration() + + self._settings = None + self._initial_settings = settings or dict() + self._settings_watchdog = None + self._settings_lock = RLock() + + # Override to register a callback method that will be called every time + # the skill's settings are updated. The referenced method should + # include any logic needed to handle the updated settings. + self.settings_change_callback = None + + # fully initialized when self.skill_id is set + self._file_system = None + + self.reload_skill = True # allow reloading (default True) + + self.events = EventContainer(bus) + + # Cached voc file contents + self._voc_cache = {} + + # loaded lang file resources + self._lang_resources = {} + + # Delegator classes + self.event_scheduler = EventSchedulerInterface() + self.intent_service = IntentServiceInterface() self.audio_service = None - super(OVOSSkill, self).__init__(*args, **kwargs) + self.intent_layers = IntentLayers() - def bind(self, bus: MessageBusClient): - super().bind(bus) - if bus: - # here to ensure self.skill_id is populated - self.private_settings = PrivateSettings(self.skill_id) - self.intent_layers.bind(self) - self.audio_service = OCPInterface(self.bus) + # Skill Public API + self.public_api: Dict[str, dict] = {} + + self._original_converse = self.converse # for get_response + self._threads = [] # for killable events decorator + + # yay, following python best practices again! + if self.skill_id and bus: + self._startup(bus, self.skill_id) + + # skill developer abstract methods + # devs are meant to override these + def initialize(self): + """ + Legacy method overridden by skills to perform extra init after __init__. + Skills should now move any code in this method to `__init__`, after a + call to `super().__init__`. + """ + pass + + def get_intro_message(self) -> str: + """ + Override to return a string to speak on first run. i.e. for post-install + setup instructions. + """ + return "" + + def handle_activate(self, message: Message): + """ + Called when this skill is considered active by the intent service; + converse method will be called with every utterance. + Override this method to do any optional preparation. + @param message: `{self.skill_id}.activate` Message + """ + + def handle_deactivate(self, message: Message): + """ + Called when this skill is no longer considered active by the intent + service; converse method will not be called until skill is active again. + Override this method to do any optional cleanup. + @param message: `{self.skill_id}.deactivate` Message + """ + + def converse(self, message: Optional[Message] = None) -> bool: + """ + Override to handle an utterance before intent parsing while this skill + is active. Active skills are called in order of most recently used to + least recently used until one handles the converse request. If no skill + handles an utterance in `converse`, then the utterance will continue to + normal intent parsing. + @param message: Message containing user utterances to optionally handle + @return: True if the utterance was handled, else False + """ + return False + + def stop(self): + """ + Optional method implemented by subclass. Called when system or user + requests `stop` to cancel current execution. + """ + pass + + def shutdown(self): + """ + Optional shutdown procedure implemented by subclass. + + This method is intended to be called during the skill process + termination. The skill implementation must shut down all processes and + operations in execution. + """ + pass + + # skill class properties + @classproperty + def runtime_requirements(self) -> RuntimeRequirements: + """ + Override to specify what a skill expects to be available at init and at + runtime. Default will assume network and internet are required and GUI + is not required for backwards-compat. + + some examples: + + IOT skill that controls skills via LAN could return: + scans_on_init = True + RuntimeRequirements(internet_before_load=False, + network_before_load=scans_on_init, + requires_internet=False, + requires_network=True, + no_internet_fallback=True, + no_network_fallback=False) + + online search skill with a local cache: + has_cache = False + RuntimeRequirements(internet_before_load=not has_cache, + network_before_load=not has_cache, + requires_internet=True, + requires_network=True, + no_internet_fallback=True, + no_network_fallback=True) + + a fully offline skill: + RuntimeRequirements(internet_before_load=False, + network_before_load=False, + requires_internet=False, + requires_network=False, + no_internet_fallback=True, + no_network_fallback=True) + """ + return RuntimeRequirements() - # new public api, these are not available in MycroftSkill @property def is_fully_initialized(self) -> bool: """ @@ -70,28 +317,202 @@ def is_fully_initialized(self) -> bool: When True, all data has been loaded and all internal state and events set up. """ - return self._is_fully_initialized + return self._init_event.is_set() @property def stop_is_implemented(self) -> bool: """ True if this skill implements a `stop` method """ - return self._stop_is_implemented + return self.__class__.stop is not OVOSSkill.stop @property def converse_is_implemented(self) -> bool: """ True if this skill implements a `converse` method """ - return self._converse_is_implemented + return self.__class__.converse is not OVOSSkill.converse or \ + self._original_converse != self.converse + + # safe skill_id/bus wrapper properties + @property + def alphanumeric_skill_id(self) -> str: + """ + Skill id converted to only alphanumeric characters and "_". + Non alphanumeric characters are converted to "_" + """ + return ''.join(c if c.isalnum() else '_' + for c in str(self.skill_id)) + + @property + def settings_path(self) -> str: + """ + Absolute file path of this skill's `settings.json` (file may not exist) + """ + return join(get_xdg_config_save_path(), 'skills', self.skill_id, + 'settings.json') + + @property + def settings(self) -> JsonStorage: + """ + Get settings specific to this skill + """ + if self._settings is not None: + return self._settings + else: + self.log.warning('Skill not fully initialized. Only default values ' + 'can be set, no settings can be read or changed.' + f"to correct this add kwargs " + f"__init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + self.log.error(simple_trace(traceback.format_stack())) + return self._initial_settings + + @settings.setter + def settings(self, val: dict): + """ + Update settings specific to this skill + """ + assert isinstance(val, dict) + # init method + if self._settings is None: + self._initial_settings = val + return + with self._settings_lock: + # ensure self._settings remains a JsonDatabase + self._settings.clear() # clear data + self._settings.merge(val, skip_empty=False) # merge new data + + @property + def enclosure(self) -> EnclosureAPI: + """ + Get an EnclosureAPI object to interact with hardware + """ + if self._enclosure: + return self._enclosure + else: + self.log.warning('Skill not fully initialized.' + f"to correct this add kwargs " + f"__init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + self.log.error(simple_trace(traceback.format_stack())) + raise Exception('Accessed MycroftSkill.enclosure in __init__') + + @property + def file_system(self) -> FileSystemAccess: + """ + Get an object that provides managed access to a local Filesystem. + """ + if not self._file_system and self.skill_id: + self._file_system = FileSystemAccess(join('skills', self.skill_id)) + if self._file_system: + return self._file_system + else: + self.log.warning('Skill not fully initialized.' + f"to correct this add kwargs __init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + self.log.error(simple_trace(traceback.format_stack())) + raise Exception('Accessed MycroftSkill.file_system in __init__') + + @file_system.setter + def file_system(self, fs: FileSystemAccess): + """ + Provided mainly for backwards compatibility with derivative + MycroftSkill classes. Skills are advised against redefining the file + system directory. + @param fs: new FileSystemAccess object to use + """ + self.log.warning(f"Skill manually overriding file_system path to: " + f"{fs.path}") + self._file_system = fs + + @property + def bus(self) -> MessageBusClient: + """ + Get the MessageBusClient bound to this skill + """ + if self._bus: + return self._bus + else: + self.log.warning('Skill not fully initialized.' + f"to correct this add kwargs " + f"__init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + self.log.error(simple_trace(traceback.format_stack())) + raise Exception('Accessed MycroftSkill.bus in __init__') + + @bus.setter + def bus(self, value: MessageBusClient): + """ + Set the MessageBusClient bound to this skill. Note that setting this + after init may have unintended consequences as expected events might + not be registered. Call `bind` to connect a new MessageBusClient. + @param value: new MessageBusClient object + """ + from ovos_bus_client import MessageBusClient + from ovos_utils.messagebus import FakeBus + if isinstance(value, (MessageBusClient, FakeBus)): + self._bus = value + else: + raise TypeError(f"Expected a MessageBusClient, got: {type(value)}") + + # magic properties -> depend on message.context / Session + @property + def dialog_renderer(self) -> Optional[MustacheDialogRenderer]: + """ + Get a dialog renderer for this skill. Language will be determined by + message history to match the language associated with the current + session or else from Configuration. + """ + return self.resources.dialog_renderer + + @property + def location(self) -> dict: + """ + Get the JSON data struction holding location information. + """ + # TODO: Allow Enclosure to override this for devices that + # contain a GPS. + return self.config_core.get('location') + + @property + def location_pretty(self) -> Optional[str]: + """ + Get a speakable city from the location config if available + """ + loc = self.location + if type(loc) is dict and loc['city']: + return loc['city']['name'] + return None + + @property + def location_timezone(self) -> Optional[str]: + """ + Get the timezone code, such as 'America/Los_Angeles' + """ + loc = self.location + if type(loc) is dict and loc['timezone']: + return loc['timezone']['code'] + return None + + @property + def lang(self) -> str: + """ + Get the current language as a BCP-47 language code. This will consider + current session data if available, else Configuration. + """ + lang = self.core_lang + message = dig_for_message() + if message: + lang = get_message_lang(message) + return lang.lower() @property def core_lang(self) -> str: """ Get the configured default language as a BCP-47 language code. """ - return self._core_lang + return self.config_core.get("lang", "en-us").lower() @property def secondary_langs(self) -> List[str]: @@ -101,7 +522,8 @@ def secondary_langs(self) -> List[str]: to `core_lang`. A skill may override this method to specify which languages intents are registered in. """ - return self._secondary_langs + return [lang.lower() for lang in self.config_core.get('secondary_langs', []) + if lang != self.core_lang] @property def native_langs(self) -> List[str]: @@ -110,15 +532,9 @@ def native_langs(self) -> List[str]: and explicitly supported). This is equivalent to normalized secondary_langs + core_lang. """ - return self._native_langs - - @property - def alphanumeric_skill_id(self) -> str: - """ - Skill id converted to only alphanumeric characters and "_". - Non alphanumeric characters are converted to "_" - """ - return self._alphanumeric_skill_id + valid = set([lang.lower() for lang in self.secondary_langs if '-' in + lang and lang != self.core_lang] + [self.core_lang]) + return list(valid) @property def resources(self) -> SkillResources: @@ -126,132 +542,257 @@ def resources(self) -> SkillResources: Get a SkillResources object for the current language. Objects are initialized for the current language as needed. """ - return self._resources + return self.load_lang(self.res_dir, self.lang) - def activate(self): + # resource file loading + def load_lang(self, root_directory: Optional[str] = None, + lang: Optional[str] = None) -> SkillResources: """ - Mark this skill as active and push to the top of the active skills list. - This enables converse method to be called even without skill being - used in last 5 minutes. + Get a SkillResources object for this skill in the requested `lang` for + resource files in the requested `root_directory`. + @param root_directory: root path to find resources (default res_dir) + @param lang: language to get resources for (default self.lang) + @return: SkillResources object """ - self._activate() + lang = lang or self.lang + root_directory = root_directory or self.res_dir + if lang not in self._lang_resources: + self._lang_resources[lang] = SkillResources(root_directory, lang, + skill_id=self.skill_id) + return self._lang_resources[lang] - def deactivate(self): + def load_dialog_files(self, root_directory: Optional[str] = None): """ - Mark this skill as inactive and remove from the active skills list. - This stops converse method from being called. + Load dialog files for all configured languages + @param root_directory: Directory to locate resources in + (default self.res_dir) """ - self._deactivate() + root_directory = root_directory or self.res_dir + # If "/dialog/" exists, load from there. Otherwise, + # load dialog from "/locale/" + for lang in self.native_langs: + resources = self.load_lang(root_directory, lang) + if resources.types.dialog.base_directory is None: + self.log.debug(f'No dialog loaded for {lang}') - @staticmethod - def __acknowledge_classic(): + def load_data_files(self, root_directory: Optional[str] = None): """ - Acknowledge a successful request. + Called by the skill loader to load intents, dialogs, etc. - This method plays a sound to acknowledge a request that does not - require a verbal response. This is intended to provide simple feedback - to the user that their request was handled successfully. + Args: + root_directory (str): root folder to use when loading files. """ - # use BaseSkill method, self.play_audio does not exist - return super().acknowledge() + root_directory = root_directory or self.res_dir + self.load_dialog_files(root_directory) + self.load_vocab_files(root_directory) + self.load_regex_files(root_directory) - @backwards_compat(classic_core=__acknowledge_classic) - def acknowledge(self): - """ - Acknowledge a successful request. + def load_vocab_files(self, root_directory: Optional[str] = None): + """ Load vocab files found under skill's root directory.""" + root_directory = root_directory or self.res_dir + for lang in self.native_langs: + resources = self.load_lang(root_directory, lang) + if resources.types.vocabulary.base_directory is None: + self.log.debug(f'No vocab loaded for {lang}') + else: + skill_vocabulary = resources.load_skill_vocabulary( + self.alphanumeric_skill_id + ) + # For each found intent register the default along with any aliases + for vocab_type in skill_vocabulary: + for line in skill_vocabulary[vocab_type]: + entity = line[0] + aliases = line[1:] + self.intent_service.register_adapt_keyword( + vocab_type, entity, aliases, lang) - This method plays a sound to acknowledge a request that does not - require a verbal response. This is intended to provide simple feedback - to the user that their request was handled successfully. + def load_regex_files(self, root_directory=None): + """ Load regex files found under the skill directory.""" + root_directory = root_directory or self.res_dir + for lang in self.native_langs: + resources = self.load_lang(root_directory, lang) + if resources.types.regex.base_directory is not None: + regexes = resources.load_skill_regex(self.alphanumeric_skill_id) + for regex in regexes: + self.intent_service.register_adapt_regex(regex, lang) + + def find_resource(self, res_name: str, res_dirname: Optional[str] = None, + lang: Optional[str] = None): """ - audio_file = self.config_core.get('sounds', {}).get('acknowledge', - 'snd/acknowledge.mp3') - self.play_audio(audio_file, instant=True) + Find a resource file. - def _play_audio_old(self, filename: str, instant: bool = False): - """ compat for ovos-core <= 0.0.7 """ - if instant: - LOG.warning("self.play_audio instant flag requires ovos-core >= 0.0.8, " - "falling back to local skill playback") - play_audio(filename).wait() - else: - message = dig_for_message() or Message("") - self.bus.emit(message.forward("mycroft.audio.queue", - {"filename": filename, # TODO - deprecate filename in ovos-audio - "uri": filename # new namespace - })) + Searches for the given filename using this scheme: + 1. Search the resource lang directory: + /// + 2. Search the resource directory: + // - def _play_audio_classic(self, filename: str, instant: bool = False): - """ compat for classic mycroft-core """ - LOG.warning("self.play_audio requires ovos-core >= 0.0.4, " - "falling back to local skill playback") - play_audio(filename).wait() + 3. Search the locale lang directory or other subdirectory: + /locale// or + /locale//.../ - @backwards_compat(pre_008=_play_audio_old, classic_core=_play_audio_classic) - def play_audio(self, filename: str, instant: bool = False): + Args: + res_name (string): The resource name to be found + res_dirname (string, optional): A skill resource directory, such + 'dialog', 'vocab', 'regex' or 'ui'. + Defaults to None. + lang (string, optional): language folder to be used. + Defaults to self.lang. + + Returns: + string: The full path to the resource file or None if not found """ - Queue and audio file for playback - @param filename: File to play - @param instant: if True audio will be played instantly instead of queued with TTS + lang = lang or self.lang + x = find_resource(res_name, self.res_dir, res_dirname, lang) + if x: + return str(x) + self.log.error(f"Skill {self.skill_id} resource '{res_name}' for lang " + f"'{lang}' not found in skill") + + # skill object setup + def _handle_first_run(self): """ - message = dig_for_message() or Message("") - if instant: - self.bus.emit(message.forward("mycroft.audio.play_sound", - {"uri": filename})) - else: - self.bus.emit(message.forward("mycroft.audio.queue", - {"filename": filename, # TODO - deprecate filename in ovos-audio - "uri": filename # new namespace - })) + The very first time a skill is run, speak a provided intro_message. + """ + intro = self.get_intro_message() + if intro: + # supports .dialog files for easy localization + # when .dialog does not exist, the text is spoken + # it is backwards compatible + self.speak_dialog(intro) - def load_lang(self, root_directory: Optional[str] = None, - lang: Optional[str] = None): + def _check_for_first_run(self): """ - Get a SkillResources object for this skill in the requested `lang` for - resource files in the requested `root_directory`. - @param root_directory: root path to find resources (default res_dir) - @param lang: language to get resources for (default self.lang) - @return: SkillResources object + Determine if this is the very first time a skill is run by looking for + `__mycroft_skill_firstrun` in skill settings. """ - return self._load_lang(root_directory, lang) + first_run = self.settings.get("__mycroft_skill_firstrun", True) + if first_run: + self.log.info("First run of " + self.skill_id) + self._handle_first_run() + self.settings["__mycroft_skill_firstrun"] = False + self.settings.store() - def voc_match(self, *args, **kwargs) -> Union[str, bool]: + def _startup(self, bus: MessageBusClient, skill_id: str = ""): """ - Wraps the default `voc_match` method, but returns `False` instead of - raising FileNotFoundError when a resource can't be resolved + Startup the skill. Connects the skill to the messagebus, loads resources + and finally calls the skill's "intialize" method. + @param bus: MessageBusClient to bind to skill + @param skill_id: Unique skill identifier, defaults to skill path for + legacy skills and python entrypoints for modern skills """ + if self.is_fully_initialized: + self.log.warning(f"Tried to initialize {self.skill_id} multiple " + f"times, ignoring") + return + + # NOTE: this method is called by SkillLoader + # it is private to make it clear to skill devs they should not touch it try: - return super().voc_match(*args, **kwargs) - except FileNotFoundError: - return False + # set the skill_id + self.skill_id = skill_id or basename(self.root_dir) + self.intent_service.set_id(self.skill_id) + self.event_scheduler.set_id(self.skill_id) + self.enclosure.set_id(self.skill_id) - def voc_list(self, voc_filename: str, - lang: Optional[str] = None) -> List[str]: + # initialize anything that depends on skill_id + self.log = LOG.create_logger(self.skill_id) + self._init_settings() + + # initialize anything that depends on the messagebus + self.bind(bus) + if not self.gui: + self._init_skill_gui() + if self._enable_settings_manager: + self._init_settings_manager() + self.load_data_files() + self._register_decorated() + self.register_resting_screen() + + # run skill developer initialization code + self.initialize() + self._check_for_first_run() + self._init_event.set() + except Exception as e: + self.log.exception('Skill initialization failed') + # If an exception occurs, attempt to clean up the skill + try: + self.default_shutdown() + except Exception as e2: + LOG.debug(e2) + raise e + + def _init_settings(self): """ - Get list of vocab options for the requested resource and cache the - results for future references. - @param voc_filename: Name of vocab resource to get options for - @param lang: language to get vocab for (default self.lang) - @return: list of string vocab options + Set up skill settings. Defines settings in the specified file path, + handles any settings passed to skill init, and starts watching the + settings file for changes. """ - return self._voc_list(voc_filename, lang) + self.log.debug(f"initializing skill settings for {self.skill_id}") - def remove_voc(self, utt: str, voc_filename: str, - lang: Optional[str] = None) -> str: + # NOTE: lock is disabled due to usage of deepcopy and to allow json + # serialization + self._settings = JsonStorage(self.settings_path, disable_lock=True) + with self._settings_lock: + if self._initial_settings and not self.is_fully_initialized: + self.log.warning("Copying default settings values defined in " + "__init__ \nto correct this add kwargs " + "__init__(bus=None, skill_id='') " + f"to skill class {self.__class__.__name__}") + for k, v in self._initial_settings.items(): + if k not in self._settings: + self._settings[k] = v + self._initial_settings = copy(self.settings) + + self._start_filewatcher() + + def _init_skill_gui(self): """ - Removes any vocab match from the utterance. - @param utt: Utterance to evaluate - @param voc_filename: vocab resource to remove from utt - @param lang: Optional language associated with vocab and utterance - @return: string with vocab removed + Set up the SkillGUI for this skill and connect relevant bus events. """ - if utt: - # Check for matches against complete words - for i in self.voc_list(voc_filename, lang): - # Substitute only whole words matching the token - utt = re.sub(r'\b' + i + r"\b", "", utt) + self.gui = SkillGUI(self) + self.gui.setup_default_handlers() - return utt + def _init_settings_manager(self): + """ + Set up the SkillSettingsManager for this skill. + """ + self.settings_manager = SkillSettingsManager(self) + + def register_resting_screen(self): + """ + Registers resting screen from the resting_screen_handler decorator. + + This only allows one screen and if two is registered only one + will be used. + """ + for attr_name in get_non_properties(self): + method = getattr(self, attr_name) + if hasattr(method, 'resting_handler'): + self.resting_name = method.resting_handler + self.log.info(f'Registering resting screen {method} for {self.resting_name}.') + + # Register for handling resting screen + self.add_event(f'{self.skill_id}.idle', method, speak_errors=False) + # Register handler for resting screen collect message + self.add_event('mycroft.mark2.collect_idle', + self._handle_collect_resting, speak_errors=False) + + # Do a send at load to make sure the skill is registered + # if reloaded + self._handle_collect_resting() + break + + def _start_filewatcher(self): + """ + Start watching settings for file changes if settings file exists and + there isn't already a FileWatcher watching it + """ + if self._settings_watchdog is None and isfile(self._settings.path): + self._settings_watchdog = \ + FileWatcher([self._settings.path], + callback=self._handle_settings_file_change, + ignore_creation=True) def _register_decorated(self): """ @@ -260,11 +801,18 @@ def _register_decorated(self): Looks for all functions that have been marked by a decorator and read the intent data from them. The intent handlers aren't the only decorators used. Skip properties as calling getattr on them - executes the code which may have unintended side-effects + executes the code which may have unintended side effects """ - super()._register_decorated() for attr_name in get_non_properties(self): method = getattr(self, attr_name) + if hasattr(method, 'intents'): + for intent in getattr(method, 'intents'): + self.register_intent(intent, method) + + if hasattr(method, 'intent_files'): + for intent_file in getattr(method, 'intent_files'): + self.register_intent_file(intent_file, method) + if hasattr(method, 'intent_layers'): for layer_name, intent_files in \ getattr(method, 'intent_layers').items(): @@ -274,17 +822,349 @@ def _register_decorated(self): if hasattr(method, 'converse'): self.converse = method - def register_intent_layer(self, layer_name: str, - intent_list: List[Union[IntentBuilder, Intent, - str]]): + def _upload_settings(self): """ - Register a named intent layer. - @param layer_name: Name of intent layer to add - @param intent_list: List of intents associated with the intent layer + Upload settings to a remote backend if configured. """ - for intent_file in intent_list: - if IntentBuilder is not None and isinstance(intent_file, IntentBuilder): - intent = intent_file.build() + if self.settings_manager and self.config_core.get("skills", + {}).get("sync2way"): + # upload new settings to backend + generate = self.config_core.get("skills", {}).get("autogen_meta", + True) + # this will check global sync flag + self.settings_manager.upload(generate) + if generate: + # update settingsmeta file on disk + self.settings_manager.save_meta() + + def __bind_classic(self, bus): + self._bus = bus + self.events.set_bus(bus) + self.intent_service.set_bus(bus) + self.event_scheduler.set_bus(bus) + self._enclosure.set_bus(bus) + self._register_system_event_handlers() + self._register_public_api() + log_deprecation("Support for mycroft-core is deprecated", + "0.1.0") + # inject ovos exclusive features in vanilla mycroft-core + # if possible + # limited support for missing skill deactivated event + # TODO - update ConverseTracker + ConverseTracker.connect_bus(self.bus) # pull/1468 + self.add_event("converse.skill.deactivated", + self._handle_skill_deactivated, + speak_errors=False) + + @backwards_compat(classic_core=__bind_classic) + def bind(self, bus: MessageBusClient): + """ + Register MessageBusClient with skill. + @param bus: MessageBusClient to bind to skill and internal objects + """ + if bus: + self._bus = bus + self.events.set_bus(bus) + self.intent_service.set_bus(bus) + self.event_scheduler.set_bus(bus) + self._enclosure.set_bus(bus) + self._register_system_event_handlers() + self._register_public_api() + self.intent_layers.bind(self) + self.audio_service = OCPInterface(self.bus) + self.private_settings = PrivateSettings(self.skill_id) + + def _register_public_api(self): + """ + Find and register API methods decorated with `@api_method` and create a + messagebus handler for fetching the api info if any handlers exist. + """ + + def wrap_method(fn): + """Boilerplate for returning the response to the sender.""" + + def wrapper(message): + result = fn(*message.data['args'], **message.data['kwargs']) + message.context["skill_id"] = self.skill_id + self.bus.emit(message.response(data={'result': result})) + + return wrapper + + methods = [attr_name for attr_name in get_non_properties(self) + if hasattr(getattr(self, attr_name), '__name__')] + + for attr_name in methods: + method = getattr(self, attr_name) + + if hasattr(method, 'api_method'): + doc = method.__doc__ or '' + name = method.__name__ + self.public_api[name] = { + 'help': doc, + 'type': f'{self.skill_id}.{name}', + 'func': method + } + for key in self.public_api: + if ('type' in self.public_api[key] and + 'func' in self.public_api[key]): + self.log.debug(f"Adding api method: " + f"{self.public_api[key]['type']}") + + # remove the function member since it shouldn't be + # reused and can't be sent over the messagebus + func = self.public_api[key].pop('func') + self.add_event(self.public_api[key]['type'], + wrap_method(func), speak_errors=False) + + if self.public_api: + self.add_event(f'{self.skill_id}.public_api', + self._send_public_api, speak_errors=False) + + def _register_system_event_handlers(self): + """ + Register default messagebus event handlers + """ + # Only register stop if it's been implemented + if self.stop_is_implemented: + self.add_event('mycroft.stop', self.__handle_stop, + speak_errors=False) + self.add_event('skill.converse.ping', self._handle_converse_ack, + speak_errors=False) + self.add_event('skill.converse.request', self._handle_converse_request, + speak_errors=False) + self.add_event(f"{self.skill_id}.activate", self.handle_activate, + speak_errors=False) + self.add_event(f"{self.skill_id}.deactivate", self.handle_deactivate, + speak_errors=False) + self.add_event("intent.service.skills.deactivated", + self._handle_skill_deactivated, speak_errors=False) + self.add_event("intent.service.skills.activated", + self._handle_skill_activated, speak_errors=False) + self.add_event('mycroft.skill.enable_intent', self.handle_enable_intent, + speak_errors=False) + self.add_event('mycroft.skill.disable_intent', + self.handle_disable_intent, speak_errors=False) + self.add_event('mycroft.skill.set_cross_context', + self.handle_set_cross_context, speak_errors=False) + self.add_event('mycroft.skill.remove_cross_context', + self.handle_remove_cross_context, speak_errors=False) + self.add_event('mycroft.skills.settings.changed', + self.handle_settings_change, speak_errors=False) + + def _send_public_api(self, message: Message): + """ + Respond with the skill's public api. + @param message: `{self.skill_id}.public_api` Message + """ + message.context["skill_id"] = self.skill_id + self.bus.emit(message.response(data=self.public_api)) + + # skill internal events amd lifecycle + def _handle_settings_file_change(self, path: str): + """ + Handle a FileWatcher notification that a file was changed. Reload + settings, call `self.settings_change_callback` if defined, and upload + changes if a backend is configured. + @param path: Modified file path + """ + if path != self._settings.path: + LOG.debug(f"Ignoring non-settings change") + return + if self._settings: + with self._settings_lock: + self._settings.reload() + if self.settings_change_callback: + try: + self.settings_change_callback() + except Exception as e: + self.log.exception("settings change callback failed, " + f"file changes not handled!: {e}") + self._upload_settings() + + def handle_settings_change(self, message: Message): + """ + Update settings if a remote settings changes apply to this skill. + + The skill settings downloader uses a single API call to retrieve the + settings for all skills to limit the number API calls. + A "mycroft.skills.settings.changed" event is emitted for each skill + with settings changes. Only update this skill's settings if its remote + settings were among those changed. + """ + remote_settings = message.data.get(self.skill_id) + if remote_settings is not None: + self.log.info('Updating settings for skill ' + self.skill_id) + self.settings.update(**remote_settings) + self.settings.store() + if self.settings_change_callback is not None: + try: + self.settings_change_callback() + except Exception as e: + self.log.exception("settings change callback failed, " + f"remote changes not handled!: {e}") + self._start_filewatcher() + + def _handle_skill_activated(self, message: Message): + """ + Intent service activated a skill. If it was this skill, + emit a skill activation message. + @param message: `intent.service.skills.activated` Message + """ + if message.data.get("skill_id") == self.skill_id: + self.bus.emit(message.forward(f"{self.skill_id}.activate")) + + def _handle_skill_deactivated(self, message): + """ + Intent service deactivated a skill. If it was this skill, + emit a skill deactivation message. + @param message: `intent.service.skills.deactivated` Message + """ + if message.data.get("skill_id") == self.skill_id: + self.bus.emit(message.forward(f"{self.skill_id}.deactivate")) + + def _handle_converse_ack(self, message: Message): + """ + Inform skills service if we want to handle converse. Individual skills + may override the property self.converse_is_implemented to enable or + disable converse support. Note that this does not affect a skill's + `active` status. + @param message: `skill.converse.ping` Message + """ + self.bus.emit(message.reply( + "skill.converse.pong", + data={"skill_id": self.skill_id, + "can_handle": self.converse_is_implemented}, + context={"skill_id": self.skill_id})) + + def _handle_converse_request(self, message: Message): + """ + If this skill is requested and supports converse, handle the user input + with `converse`. + @param message: `skill.converse.request` Message + """ + skill_id = message.data['skill_id'] + if skill_id == self.skill_id: + try: + # converse can have multiple signatures + params = signature(self.converse).parameters + kwargs = {"message": message, + "utterances": message.data['utterances'], + "lang": message.data['lang']} + kwargs = {k: v for k, v in kwargs.items() if k in params} + result = self.converse(**kwargs) + self.bus.emit(message.reply('skill.converse.response', + {"skill_id": self.skill_id, + "result": result})) + except Exception as e: + LOG.error(e) + self.bus.emit(message.reply('skill.converse.response', + {"skill_id": self.skill_id, + "result": False})) + + def _handle_collect_resting(self, message: Optional[Message] = None): + """ + Handler for collect resting screen messages. + + Sends info on how to trigger this skill's resting page. + """ + self.log.info('Registering resting screen') + msg = message or Message("") + message = msg.reply( + 'mycroft.mark2.register_idle', + data={'name': self.resting_name, 'id': self.skill_id}, + context={"skill_id": self.skill_id} + ) + self.bus.emit(message) + + def __handle_stop(self, message): + """Handler for the "mycroft.stop" signal. Runs the user defined + `stop()` method. + """ + message.context['skill_id'] = self.skill_id + self.bus.emit(message.forward(self.skill_id + ".stop")) + try: + if self.stop(): + self.bus.emit(message.reply("mycroft.stop.handled", + {"by": "skill:" + self.skill_id}, + {"skill_id": self.skill_id})) + except Exception as e: + self.log.exception(f'Failed to stop skill: {self.skill_id}: {e}') + + def default_shutdown(self): + """ + Parent function called internally to shut down everything. + 1) Call skill.stop() to allow skill to clean up any active processes + 2) Store skill settings and remove file watchers + 3) Shutdown skill GUI to clear any active pages + 4) Shutdown the event_scheduler and remove any pending events + 5) Call skill.shutdown() to allow skill to do any other shutdown tasks + 6) Emit `detach_skill` Message to notify skill is shut down + """ + + try: + # Allow skill to handle `stop` actions before shutting things down + self.stop() + except Exception as e: + self.log.error(f'Failed to stop skill: {self.skill_id}: {e}', + exc_info=True) + + try: + self.settings_change_callback = None + + # Store settings + if self.settings != self._initial_settings: + self.settings.store() + if self._settings_meta: + self._settings_meta.stop() + if self._settings_watchdog: + self._settings_watchdog.shutdown() + except Exception as e: + self.log.error(f"Failed to store settings for {self.skill_id}: {e}") + + try: + # Clear skill from gui + if self.gui: + self.gui.shutdown() + except Exception as e: + self.log.error(f"Failed to shutdown gui for {self.skill_id}: {e}") + + try: + # removing events + if self.event_scheduler: + self.event_scheduler.shutdown() + self.events.clear() + except Exception as e: + self.log.error(f"Failed to remove events for {self.skill_id}: {e}") + + try: + self.shutdown() + except Exception as e: + self.log.error(f'Skill specific shutdown function encountered an ' + f'error: {e}') + + self.bus.emit( + Message('detach_skill', {'skill_id': f"{self.skill_id}:"}, + {"skill_id": self.skill_id})) + + def detach(self): + """ + Detach all intents for this skill from the intent_service. + """ + for (name, _) in self.intent_service: + name = f'{self.skill_id}:{name}' + self.intent_service.detach_intent(name) + + # intents / resource files management + def register_intent_layer(self, layer_name: str, + intent_list: List[Union[IntentBuilder, Intent, str]]): + """ + Register a named intent layer. + @param layer_name: Name of intent layer to add + @param intent_list: List of intents associated with the intent layer + """ + for intent_file in intent_list: + if IntentBuilder is not None and isinstance(intent_file, IntentBuilder): + intent = intent_file.build() name = intent.name elif Intent is not None and isinstance(intent_file, Intent): name = intent_file.name @@ -292,6 +1172,983 @@ def register_intent_layer(self, layer_name: str, name = f'{self.skill_id}:{intent_file}' self.intent_layers.update_layer(layer_name, [name]) + def register_intent(self, intent_parser: Union[IntentBuilder, Intent, str], + handler: callable): + """ + Register an Intent with the intent service. + + Args: + intent_parser: Intent, IntentBuilder object or padatious intent + file to parse utterance for the handler. + handler (func): function to register with intent + """ + if isinstance(intent_parser, IntentBuilder): + intent_parser = intent_parser.build() + if (isinstance(intent_parser, str) and + intent_parser.endswith('.intent')): + return self.register_intent_file(intent_parser, handler) + elif not isinstance(intent_parser, Intent): + raise ValueError('"' + str(intent_parser) + '" is not an Intent') + + return self._register_adapt_intent(intent_parser, handler) + + def register_intent_file(self, intent_file: str, handler: callable): + """Register an Intent file with the intent service. + + For example: + food.order.intent: + Order some {food}. + Order some {food} from {place}. + I'm hungry. + Grab some {food} from {place}. + + Optionally, you can also use + to specify some examples of {food} and {place} + + In addition, instead of writing out multiple variations + of the same sentence you can write: + food.order.intent: + (Order | Grab) some {food} (from {place} | ). + I'm hungry. + + Args: + intent_file: name of file that contains example queries + that should activate the intent. Must end with + '.intent' + handler: function to register with intent + """ + for lang in self.native_langs: + name = f'{self.skill_id}:{intent_file}' + resources = self.load_lang(self.res_dir, lang) + resource_file = ResourceFile(resources.types.intent, intent_file) + if resource_file.file_path is None: + self.log.error(f'Unable to find "{intent_file}"') + continue + filename = str(resource_file.file_path) + self.intent_service.register_padatious_intent(name, filename, lang) + if handler: + self.add_event(name, handler, 'mycroft.skill.handler') + + def register_entity_file(self, entity_file: str): + """ + Register an Entity file with the intent service. + + An Entity file lists the exact values that an entity can hold. + For example: + ask.day.intent: + Is it {weekend}? + weekend.entity: + Saturday + Sunday + + Args: + entity_file (string): name of file that contains examples of an + entity. + """ + if entity_file.endswith('.entity'): + entity_file = entity_file.replace('.entity', '') + for lang in self.native_langs: + resources = self.load_lang(self.res_dir, lang) + entity = ResourceFile(resources.types.entity, entity_file) + if entity.file_path is None: + self.log.error(f'Unable to find "{entity_file}"') + continue + filename = str(entity.file_path) + name = f"{self.skill_id}:{basename(entity_file)}_" \ + f"{md5(entity_file.encode('utf-8')).hexdigest()}" + self.intent_service.register_padatious_entity(name, filename, lang) + + def register_vocabulary(self, entity: str, entity_type: str, + lang: Optional[str] = None): + """ + Register a word to a keyword + @param entity: word to register + @param entity_type: Intent handler entity name to associate entity to + @param lang: language of `entity` (default self.lang) + """ + keyword_type = self.alphanumeric_skill_id + entity_type + lang = lang or self.lang + self.intent_service.register_adapt_keyword(keyword_type, entity, + lang=lang) + + def register_regex(self, regex_str: str, lang: Optional[str] = None): + """ + Register a new regex. + @param regex_str: Regex string to add + @param lang: language of regex_str (default self.lang) + """ + self.log.debug('registering regex string: ' + regex_str) + regex = munge_regex(regex_str, self.skill_id) + re.compile(regex) # validate regex + self.intent_service.register_adapt_regex(regex, lang=lang or self.lang) + + # event/intent registering internal handlers + def handle_enable_intent(self, message: Message): + """ + Listener to enable a registered intent if it belongs to this skill. + @param message: `mycroft.skill.enable_intent` Message + """ + intent_name = message.data['intent_name'] + for (name, _) in self.intent_service.detached_intents: + if name == intent_name: + return self.enable_intent(intent_name) + + def handle_disable_intent(self, message: Message): + """ + Listener to disable a registered intent if it belongs to this skill. + @param message: `mycroft.skill.disable_intent` Message + """ + intent_name = message.data['intent_name'] + for (name, _) in self.intent_service.registered_intents: + if name == intent_name: + return self.disable_intent(intent_name) + + def handle_set_cross_context(self, message: Message): + """ + Add global context to the intent service. + @param message: `mycroft.skill.set_cross_context` Message + """ + context = message.data.get('context') + word = message.data.get('word') + origin = message.data.get('origin') + + self.set_context(context, word, origin) + + def handle_remove_cross_context(self, message: Message): + """ + Remove global context from the intent service. + @param message: `mycroft.skill.remove_cross_context` Message + """ + context = message.data.get('context') + self.remove_context(context) + + def _on_event_start(self, message: Message, handler_info: str, + skill_data: dict): + """ + Indicate that the skill handler is starting. + """ + if handler_info: + # Indicate that the skill handler is starting if requested + msg_type = handler_info + '.start' + message.context["skill_id"] = self.skill_id + self.bus.emit(message.forward(msg_type, skill_data)) + + def _on_event_end(self, message: Message, handler_info: str, + skill_data: dict): + """ + Store settings (if changed) and indicate that the skill handler has + completed. + """ + if self.settings != self._initial_settings: + self.settings.store() + self._initial_settings = copy(self.settings) + if handler_info: + msg_type = handler_info + '.complete' + message.context["skill_id"] = self.skill_id + self.bus.emit(message.forward(msg_type, skill_data)) + + def _on_event_error(self, error: str, message: Message, handler_info: str, + skill_data: dict, speak_errors: bool): + """Speak and log the error.""" + # Convert "MyFancySkill" to "My Fancy Skill" for speaking + handler_name = camel_case_split(self.name) + msg_data = {'skill': handler_name} + speech = get_dialog('skill.error', self.lang, msg_data) + if speak_errors: + self.speak(speech) + self.log.exception(error) + # append exception information in message + skill_data['exception'] = repr(error) + if handler_info: + # Indicate that the skill handler errored + msg_type = handler_info + '.error' + message = message or Message("") + message.context["skill_id"] = self.skill_id + self.bus.emit(message.forward(msg_type, skill_data)) + + def _register_adapt_intent(self, + intent_parser: Union[IntentBuilder, Intent, str], + handler: callable): + """ + Register an adapt intent. + + Args: + intent_parser: Intent object to parse utterance for the handler. + handler (func): function to register with intent + """ + # Default to the handler's function name if none given + is_anonymous = not intent_parser.name + name = intent_parser.name or handler.__name__ + if is_anonymous: + # Find a good name + original_name = name + nbr = 0 + while name in self.intent_service.intent_names: + nbr += 1 + name = f'{original_name}{nbr}' + elif name in self.intent_service.intent_names and \ + not self.intent_service.intent_is_detached(name): + raise ValueError(f'The intent name {name} is already taken') + + munge_intent_parser(intent_parser, name, self.skill_id) + self.intent_service.register_adapt_intent(name, intent_parser) + if handler: + self.add_event(intent_parser.name, handler, + 'mycroft.skill.handler') + + # skill developer facing utils + def speak(self, utterance: str, expect_response: bool = False, + wait: bool = False, meta: Optional[dict] = None): + """Speak a sentence. + + Args: + utterance (str): sentence mycroft should speak + expect_response (bool): set to True if Mycroft should listen + for a response immediately after + speaking the utterance. + wait (bool): set to True to block while the text + is being spoken. + meta: Information of what built the sentence. + """ + # registers the skill as being active + meta = meta or {} + meta['skill'] = self.skill_id + self.enclosure.register(self.skill_id) + data = {'utterance': utterance, + 'expect_response': expect_response, + 'meta': meta, + 'lang': self.lang} + + # grab message that triggered speech so we can keep context + message = dig_for_message() + m = message.forward("speak", data) if message \ + else Message("speak", data) + m.context["skill_id"] = self.skill_id + + # update any auto-translation metadata in message.context + if "translation_data" in meta: + tx_data = merge_dict(m.context.get("translation_data", {}), + meta["translation_data"]) + m.context["translation_data"] = tx_data + + self.bus.emit(m) + + if wait: + sessid = SessionManager.get(m).session_id + event = Event() + + def handle_output_end(msg): + sess = SessionManager.get(msg) + if sessid == sess.session_id: + event.set() + + self.bus.on("recognizer_loop:audio_output_end", handle_output_end) + event.wait(timeout=15) + self.bus.remove("recognizer_loop:audio_output_end", + handle_output_end) + + def speak_dialog(self, key: str, data: Optional[dict] = None, + expect_response: bool = False, wait: bool = False): + """ + Speak a random sentence from a dialog file. + + Args: + key (str): dialog file key (e.g. "hello" to speak from the file + "locale/en-us/hello.dialog") + data (dict): information used to populate sentence + expect_response (bool): set to True if Mycroft should listen + for a response immediately after + speaking the utterance. + wait (bool): set to True to block while the text + is being spoken. + """ + if self.dialog_renderer: + data = data or {} + self.speak( + self.dialog_renderer.render(key, data), + expect_response, wait, meta={'dialog': key, 'data': data} + ) + else: + self.log.warning( + 'dialog_render is None, does the locale/dialog folder exist?' + ) + self.speak(key, expect_response, wait, {}) + + def _play_audio_old(self, filename: str, instant: bool = False): + """ compat for ovos-core <= 0.0.7 """ + if instant: + LOG.warning("self.play_audio instant flag requires ovos-core >= 0.0.8, " + "falling back to local skill playback") + play_audio(filename).wait() + else: + message = dig_for_message() or Message("") + self.bus.emit(message.forward("mycroft.audio.queue", + {"filename": filename, # TODO - deprecate filename in ovos-audio + "uri": filename # new namespace + })) + + def _play_audio_classic(self, filename: str, instant: bool = False): + """ compat for classic mycroft-core """ + LOG.warning("self.play_audio requires ovos-core >= 0.0.4, " + "falling back to local skill playback") + play_audio(filename).wait() + + @backwards_compat(pre_008=_play_audio_old, classic_core=_play_audio_classic) + def play_audio(self, filename: str, instant: bool = False): + """ + Queue and audio file for playback + @param filename: File to play + @param instant: if True audio will be played instantly instead of queued with TTS + """ + message = dig_for_message() or Message("") + if instant: + self.bus.emit(message.forward("mycroft.audio.play_sound", + {"uri": filename})) + else: + self.bus.emit(message.forward("mycroft.audio.queue", + {"uri": filename})) + + def __get_response(self): + """ + Helper to get a response from the user + + NOTE: There is a race condition here. There is a small amount of + time between the end of the device speaking and the converse method + being overridden in this method. If an utterance is injected during + this time, the wrong converse method is executed. The condition is + hidden during normal use due to the amount of time it takes a user + to speak a response. The condition is revealed when an automated + process injects an utterance quicker than this method can flip the + converse methods. + + Returns: + str: user's response or None on a timeout + """ + + # TODO: Support `message` signature like default? + def converse(utterances, lang=None): + converse.response = utterances[0] if utterances else None + converse.finished = True + return True + + # install a temporary conversation handler + self.activate() + converse.finished = False + converse.response = None + self.converse = converse + + # 10 for listener, 5 for SST, then timeout + # NOTE: a threading.Event is not used otherwise we can't raise the + # AbortEvent exception to kill the thread + start = time.time() + while time.time() - start <= 15 and not converse.finished: + # TODO: Refactor to event-based handling + time.sleep(0.1) + if self.__response is not False: + if self.__response is None: + # aborted externally (if None) + self.log.debug("get_response aborted") + converse.finished = True + converse.response = self.__response # external override + self.converse = self._original_converse + return converse.response + + def get_response(self, dialog: str = '', data: Optional[dict] = None, + validator: Optional[Callable[[str], bool]] = None, + on_fail: Optional[Union[str, Callable[[str], str]]] = None, + num_retries: int = -1) -> Optional[str]: + """ + Get a response from the user. If a dialog is supplied it is spoken, + followed immediately by listening for a user response. If the dialog is + omitted, listening is started directly. The response may optionally be + validated before returning. + @param dialog: Optional dialog resource or string to speak + @param data: Optional data to render dialog with + @param validator: Optional method to validate user input with. Accepts + the user's utterance as an arg and returns True if it is valid. + @param on_fail: Optional string or method that accepts a failing + utterance and returns a string to be spoken when validation fails. + @param num_retries: Number of times to retry getting a user response; + -1 will retry infinitely. + * If the user asks to "cancel", this method will exit + * If the user doesn't respond and this is `-1` this will only retry + once. + @return: String user response (None if no valid response is given) + """ + data = data or {} + + def on_fail_default(utterance): + fail_data = data.copy() + fail_data['utterance'] = utterance + if on_fail: + if self.dialog_renderer: + return self.dialog_renderer.render(on_fail, fail_data) + return on_fail + else: + if self.dialog_renderer: + return self.dialog_renderer.render(dialog, data) + return dialog + + def is_cancel(utterance): + return self.voc_match(utterance, 'cancel') + + def validator_default(utterance): + # accept anything except 'cancel' + return not is_cancel(utterance) + + on_fail_fn = on_fail if callable(on_fail) else on_fail_default + validator = validator or validator_default + + # Speak query and wait for user response + if dialog: + self.speak_dialog(dialog, data, expect_response=True, wait=True) + else: + msg = dig_for_message() + msg = msg.reply('mycroft.mic.listen') if msg else \ + Message('mycroft.mic.listen', + context={"skill_id": self.skill_id}) + self.bus.emit(msg) + return self._wait_response(is_cancel, validator, on_fail_fn, + num_retries) + + def _wait_response(self, is_cancel: callable, validator: callable, + on_fail: callable, num_retries: int) -> Optional[str]: + """ + Loop until a valid response is received from the user or the retry + limit is reached. + @param is_cancel: Function that returns `True` if user asked to cancel + @param validator: Function that returns `True` if user input is valid + @param on_fail: Function to call if validator returns `False` + @param num_retries: Number of times to retry getting a response + @returns: User response if validated, else None + """ + self.__response = False + self._real_wait_response(is_cancel, validator, on_fail, num_retries) + while self.__response is False: + # TODO: Refactor to Event + time.sleep(0.1) + return self.__response or None + + def _handle_killed_wait_response(self): + """ + Handle "stop" request when getting a response. + """ + self.__response = None + self.converse = self._original_converse + + @killable_event("mycroft.skills.abort_question", exc=AbortQuestion, + callback=_handle_killed_wait_response, react_to_stop=True) + def _real_wait_response(self, is_cancel, validator, on_fail, num_retries): + """ + Loop until a valid response is received from the user or the retry + limit is reached. + + Arguments: + is_cancel (callable): function checking cancel criteria + validator (callbale): function checking for a valid response + on_fail (callable): function handling retries + + """ + msg = dig_for_message() + msg = msg.reply('mycroft.mic.listen') if msg else \ + Message('mycroft.mic.listen', + context={"skill_id": self.skill_id}) + + num_fails = 0 + while True: + if self.__response is not False: + # usually None when aborted externally + # also allows overriding returned result from other events + return self.__response + + response = self.__get_response() + + if response is None: + # if nothing said, prompt one more time + num_none_fails = 1 if num_retries < 0 else num_retries + if num_fails >= num_none_fails: + self.__response = None + return + else: + # catch user saying 'cancel' + if is_cancel(response): + self.__response = None + return + + validated = validator(response) + # returns the validated value or the response + # (backwards compat) + if validated is not False and validated is not None: + self.__response = response if validated is True else validated + return + + num_fails += 1 + if 0 < num_retries < num_fails or self.__response is not False: + self.__response = None + return + + line = on_fail(response) + if line: + self.speak(line, expect_response=True) + else: + self.bus.emit(msg) + + @staticmethod + def __acknowledge_classic(): + """ + Acknowledge a successful request. + + This method plays a sound to acknowledge a request that does not + require a verbal response. This is intended to provide simple feedback + to the user that their request was handled successfully. + """ + # DEPRECATED - note that this is a staticmethod and uses the old endpoint + # the OVOSSkill class does things properly + from ovos_utils.sound import play_acknowledge_sound + return play_acknowledge_sound() + + @backwards_compat(classic_core=__acknowledge_classic) + def acknowledge(self): + """ + Acknowledge a successful request. + + This method plays a sound to acknowledge a request that does not + require a verbal response. This is intended to provide simple feedback + to the user that their request was handled successfully. + """ + audio_file = self.config_core.get('sounds', {}).get('acknowledge', + 'snd/acknowledge.mp3') + self.play_audio(audio_file, instant=True) + + def ask_yesno(self, prompt: str, + data: Optional[dict] = None) -> Optional[str]: + """ + Read prompt and wait for a yes/no answer. This automatically deals with + translation and common variants, such as 'yeah', 'sure', etc. + @param prompt: a dialog id or string to read + @param data: optional data to render dialog with + @return: 'yes', 'no' or the user response if not matched to 'yes' or + 'no', including a response of None. + """ + resp = self.get_response(dialog=prompt, data=data) + answer = yes_or_no(resp, lang=self.lang) if resp else resp + if answer is True: + return "yes" + elif answer is False: + return "no" + else: + return resp + + def ask_selection(self, options: List[str], dialog: str = '', + data: Optional[dict] = None, min_conf: float = 0.65, + numeric: bool = False): + """ + Read options, ask dialog question and wait for an answer. + + This automatically deals with fuzzy matching and selection by number + e.g. + + * "first option" + * "last option" + * "second option" + * "option number four" + + Args: + options (list): list of options to present user + dialog (str): a dialog id or string to read AFTER all options + data (dict): Data used to render the dialog + min_conf (float): minimum confidence for fuzzy match, if not + reached return None + numeric (bool): speak options as a numeric menu + Returns: + string: list element selected by user, or None + """ + assert isinstance(options, list) + + if not len(options): + return None + elif len(options) == 1: + return options[0] + + if numeric: + for idx, opt in enumerate(options): + number = pronounce_number(idx + 1, self.lang) + self.speak(f"{number}, {opt}", wait=True) + else: + opt_str = join_list(options, "or", lang=self.lang) + "?" + self.speak(opt_str, wait=True) + + resp = self.get_response(dialog=dialog, data=data) + + if resp: + match, score = match_one(resp, options) + if score < min_conf: + if self.voc_match(resp, 'last'): + resp = options[-1] + else: + num = extract_number(resp, ordinals=True, lang=self.lang) + resp = None + if num and num <= len(options): + resp = options[num - 1] + else: + resp = match + return resp + + def voc_list(self, voc_filename: str, + lang: Optional[str] = None) -> List[str]: + """ + Get list of vocab options for the requested resource and cache the + results for future references. + @param voc_filename: Name of vocab resource to get options for + @param lang: language to get vocab for (default self.lang) + @return: list of string vocab options + """ + lang = lang or self.lang + cache_key = lang + voc_filename + + if cache_key not in self._voc_cache: + vocab = self.resources.load_vocabulary_file(voc_filename) or \ + CoreResources(lang).load_vocabulary_file(voc_filename) + if vocab: + self._voc_cache[cache_key] = list(chain(*vocab)) + + return self._voc_cache.get(cache_key) or [] + + def voc_match(self, utt: str, voc_filename: str, lang: Optional[str] = None, + exact: bool = False): + """ + Determine if the given utterance contains the vocabulary provided. + + By default the method checks if the utterance contains the given vocab + thereby allowing the user to say things like "yes, please" and still + match against "Yes.voc" containing only "yes". An exact match can be + requested. + + The method first checks in the current Skill's .voc files and secondly + in the "res/text" folder of mycroft-core. The result is cached to + avoid hitting the disk each time the method is called. + + Args: + utt (str): Utterance to be tested + voc_filename (str): Name of vocabulary file (e.g. 'yes' for + 'res/text/en-us/yes.voc') + lang (str): Language code, defaults to self.lang + exact (bool): Whether the vocab must exactly match the utterance + + Returns: + bool: True if the utterance has the given vocabulary it + """ + match = False + try: + _vocs = self.voc_list(voc_filename, lang) + except FileNotFoundError: + return False + + if utt and _vocs: + if exact: + # Check for exact match + match = any(i.strip() == utt + for i in _vocs) + else: + # Check for matches against complete words + match = any([re.match(r'.*\b' + i + r'\b.*', utt) + for i in _vocs]) + + return match + + def remove_voc(self, utt: str, voc_filename: str, + lang: Optional[str] = None) -> str: + """ + Removes any vocab match from the utterance. + @param utt: Utterance to evaluate + @param voc_filename: vocab resource to remove from utt + @param lang: Optional language associated with vocab and utterance + @return: string with vocab removed + """ + if utt: + # Check for matches against complete words + for i in self.voc_list(voc_filename, lang): + # Substitute only whole words matching the token + utt = re.sub(r'\b' + i + r"\b", "", utt) + return utt + + # event related skill developer facing utils + def add_event(self, name: str, handler: callable, + handler_info: Optional[str] = None, once: bool = False, + speak_errors: bool = True): + """ + Create event handler for executing intent or other event. + + Args: + name (string): IntentParser name + handler (func): Method to call + handler_info (string): Base message when reporting skill event + handler status on messagebus. + once (bool, optional): Event handler will be removed after it has + been run once. + speak_errors (bool, optional): Determines if an error dialog should be + spoken to inform the user whenever + an exception happens inside the handler + """ + skill_data = {'name': get_handler_name(handler)} + + def on_error(error, message): + if isinstance(error, AbortEvent): + self.log.info("Skill execution aborted") + self._on_event_end(message, handler_info, skill_data) + return + self._on_event_error(error, message, handler_info, skill_data, + speak_errors) + + def on_start(message): + self._on_event_start(message, handler_info, skill_data) + + def on_end(message): + self._on_event_end(message, handler_info, skill_data) + + wrapper = create_wrapper(handler, self.skill_id, on_start, on_end, + on_error) + return self.events.add(name, wrapper, once) + + def remove_event(self, name: str) -> bool: + """ + Removes an event from bus emitter and events list. + + Args: + name (string): Name of Intent or Scheduler Event + Returns: + bool: True if found and removed, False if not found + """ + return self.events.remove(name) + + def schedule_event(self, handler: callable, + when: Union[int, float, datetime.datetime], + data: Optional[dict] = None, name: Optional[str] = None, + context: Optional[dict] = None): + """ + Schedule a single-shot event. + + Args: + handler: method to be called + when (datetime/int/float): datetime (in system timezone) or + number of seconds in the future when the + handler should be called + data (dict, optional): data to send when the handler is called + name (str, optional): reference name + NOTE: This will not warn or replace a + previously scheduled event of the same + name. + context (dict, optional): context (dict, optional): message + context to send when the handler + is called + """ + message = dig_for_message() + context = context or message.context if message else {} + context["skill_id"] = self.skill_id + return self.event_scheduler.schedule_event(handler, when, data, name, + context=context) + + def schedule_repeating_event(self, handler: callable, + when: Union[int, float, datetime.datetime], + frequency: Union[int, float], + data: Optional[dict] = None, + name: Optional[str] = None, + context: Optional[dict] = None): + """ + Schedule a repeating event. + + Args: + handler: method to be called + when (datetime): time (in system timezone) for first + calling the handler, or None to + initially trigger seconds + from now + frequency (float/int): time in seconds between calls + data (dict, optional): data to send when the handler is called + name (str, optional): reference name, must be unique + context (dict, optional): context (dict, optional): message + context to send when the handler + is called + """ + message = dig_for_message() + context = context or message.context if message else {} + context["skill_id"] = self.skill_id + self.event_scheduler.schedule_repeating_event(handler, when, frequency, + data, name, + context=context) + + def update_scheduled_event(self, name: str, data: Optional[dict] = None): + """ + Change data of event. + + Args: + name (str): reference name of event (from original scheduling) + data (dict): event data + """ + self.event_scheduler.update_scheduled_event(name, data) + + def cancel_scheduled_event(self, name: str): + """ + Cancel a pending event. The event will no longer be scheduled + to be executed + + Args: + name (str): reference name of event (from original scheduling) + """ + self.event_scheduler.cancel_scheduled_event(name) + + def get_scheduled_event_status(self, name: str) -> int: + """Get scheduled event data and return the amount of time left + + Args: + name (str): reference name of event (from original scheduling) + + Returns: + int: the time left in seconds + + Raises: + Exception: Raised if event is not found + """ + return self.event_scheduler.get_scheduled_event_status(name) + + def cancel_all_repeating_events(self): + """ + Cancel any repeating events started by the skill. + """ + self.event_scheduler.cancel_all_repeating_events() + + # intent/context skill dev facing utils + def activate(self): + """ + Mark this skill as active and push to the top of the active skills list. + This enables converse method to be called even without skill being + used in last 5 minutes. + """ + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + + m1 = msg.forward("intent.service.skills.activate", + data={"skill_id": self.skill_id}) + self.bus.emit(m1) + + # backwards compat with mycroft-core + # TODO - remove soon + m2 = msg.forward("active_skill_request", + data={"skill_id": self.skill_id}) + self.bus.emit(m2) + + def deactivate(self): + """ + Mark this skill as inactive and remove from the active skills list. + This stops converse method from being called. + """ + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward(f"intent.service.skills.deactivate", + data={"skill_id": self.skill_id})) + + def disable_intent(self, intent_name: str) -> bool: + """ + Disable a registered intent if it belongs to this skill. + + Args: + intent_name (string): name of the intent to be disabled + + Returns: + bool: True if disabled, False if it wasn't registered + """ + if intent_name in self.intent_service: + self.log.info('Disabling intent ' + intent_name) + name = f'{self.skill_id}:{intent_name}' + self.intent_service.detach_intent(name) + + langs = [self.core_lang] + self.secondary_langs + for lang in langs: + lang_intent_name = f'{name}_{lang}' + self.intent_service.detach_intent(lang_intent_name) + return True + else: + self.log.error(f'Could not disable {intent_name}, it hasn\'t been registered.') + return False + + def enable_intent(self, intent_name: str) -> bool: + """ + (Re)Enable a registered intent if it belongs to this skill. + + Args: + intent_name: name of the intent to be enabled + + Returns: + bool: True if enabled, False if it wasn't registered + """ + intent = self.intent_service.get_intent(intent_name) + if intent: + if ".intent" in intent_name: + self.register_intent_file(intent_name, None) + else: + intent.name = intent_name + self.register_intent(intent, None) + self.log.debug(f'Enabling intent {intent_name}') + return True + else: + self.log.error(f'Could not enable {intent_name}, it hasn\'t been registered.') + return False + + def set_context(self, context: str, word: str = '', origin: str = ''): + """ + Add context to intent service + + Args: + context: Keyword + word: word connected to keyword + origin: origin of context + """ + if not isinstance(context, str): + raise ValueError('Context should be a string') + if not isinstance(word, str): + raise ValueError('Word should be a string') + + context = self.alphanumeric_skill_id + context + self.intent_service.set_adapt_context(context, word, origin) + + def remove_context(self, context: str): + """ + Remove a keyword from the context manager. + """ + if not isinstance(context, str): + raise ValueError('context should be a string') + context = self.alphanumeric_skill_id + context + self.intent_service.remove_adapt_context(context) + + def set_cross_skill_context(self, context: str, word: str = ''): + """ + Tell all skills to add a context to the intent service + + Args: + context: Keyword + word: word connected to keyword + """ + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward('mycroft.skill.set_cross_context', + {'context': context, 'word': word, + 'origin': self.skill_id})) + + def remove_cross_skill_context(self, context: str): + """ + Tell all skills to remove a keyword from the context manager. + """ + if not isinstance(context, str): + raise ValueError('context should be a string') + msg = dig_for_message() or Message("") + if "skill_id" not in msg.context: + msg.context["skill_id"] = self.skill_id + self.bus.emit(msg.forward('mycroft.skill.remove_cross_context', + {'context': context})) + # killable_events support def __send_stop_signal_classic(self, stop_event: Optional[str] = None): """ @@ -342,6 +2199,127 @@ def send_stop_signal(self, stop_event: Optional[str] = None): waiter.wait(0.5) # if TTS had not yet started self.bus.emit(msg.forward("mycroft.audio.speech.stop")) + # below deprecated and marked for removal + @deprecated("use MetricsApi().report_metric", "0.1.0") + def report_metric(self, name: str, data: dict): + """ + Report a skill metric to the Mycroft servers. + + Args: + name (str): Name of metric. Must use only letters and hyphens + data (dict): JSON dictionary to report. Must be valid JSON + """ + try: + if Configuration().get('opt_in', False): + MetricsApi().report_metric(name, data) + except Exception as e: + self.log.error(f'Metric couldn\'t be uploaded, due to a network error ({e})') + + @deprecated("use EmailApi().send_email", "0.1.0") + def send_email(self, title: str, body: str): + """ + Send an email to the registered user's email. + + Args: + title (str): Title of email + body (str): HTML body of email. This supports + simple HTML like bold and italics + """ + EmailApi().send_email(title, body, self.skill_id) + + @classproperty + def network_requirements(self) -> RuntimeRequirements: + LOG.warning("network_requirements renamed to runtime_requirements, " + "will be removed in ovos-core 0.0.8") + return self.runtime_requirements + + @property + def voc_match_cache(self) -> Dict[str, List[str]]: + """ + Backwards-compatible accessor method for vocab cache + @return: dict vocab resources to parsed resources + """ + return self._voc_cache + + @voc_match_cache.setter + def voc_match_cache(self, val): + self.log.warning("self._voc_cache should not be modified externally. This" + "functionality will be deprecated in a future release") + if isinstance(val, dict): + self._voc_cache = val + + # below only for api compat with MycroftSkill class + @deprecated("Use `self.resources.render_dialog`", "0.1.0") + def translate(self, text: str, data: Optional[dict] = None): + """ + Deprecated method for translating a dialog file. + use self.resources.render_dialog(text, data) instead + """ + return self.resources.render_dialog(text, data) + + @deprecated("Use `self.resources.load_named_value_file`", "0.1.0") + def translate_namedvalues(self, name: str, delim: str = ','): + """ + Deprecated method for translating a name/value file. + use self.resources.load_named_value_filetext, data) instead + """ + return self.resources.load_named_value_file(name, delim) + + @deprecated("Use `self.resources.load_list_file`", "0.1.0") + def translate_list(self, list_name: str, data: Optional[dict] = None): + """ + Deprecated method for translating a list. + use self.resources.load_list_file(text, data) instead + """ + return self.resources.load_list_file(list_name, data) + + @deprecated("Use `self.resources.load_template_file`", "0.1.0") + def translate_template(self, template_name: str, + data: Optional[dict] = None): + """ + Deprecated method for translating a template file + use self.resources.template_file(text, data) instead + """ + return self.resources.load_template_file(template_name, data) + + @deprecated("Use `self.resources.load_dialog_files`", "0.1.0") + def init_dialog(self, root_directory: Optional[str] = None): + """ + DEPRECATED: use load_dialog_files instead + """ + self.load_dialog_files(root_directory) + + @deprecated("Use `activate`", "0.1.0") + def make_active(self): + """ + Bump skill to active_skill list in intent_service. + + This enables converse method to be called even without skill being + used in last 5 minutes. + + deprecated: use self.activate() instead + """ + self.activate() + + +class SkillGUI(GUIInterface): + def __init__(self, skill: OVOSSkill): + """ + Wraps `GUIInterface` for use with a skill. + """ + self._skill = skill + skill_id = skill.skill_id + bus = skill.bus + config = skill.config_core.get('gui') + ui_directories = get_ui_directories(skill.root_dir) + GUIInterface.__init__(self, skill_id=skill_id, bus=bus, config=config, + ui_directories=ui_directories) + + @property + @deprecated("`skill` should not be referenced directly", "0.1.0") + def skill(self): + return self._skill + # backwards compat alias, no functional difference class OVOSFallbackSkill(OVOSSkill): diff --git a/test/unittests/skills/test_base.py b/test/unittests/skills/test_base.py index 6d139dab..b694deed 100644 --- a/test/unittests/skills/test_base.py +++ b/test/unittests/skills/test_base.py @@ -38,7 +38,7 @@ def tearDownClass(cls) -> None: def test_00_skill_init(self): from ovos_workshop.settings import SkillSettingsManager - from ovos_workshop.skills.base import SkillGUI + from ovos_workshop.skills.ovos import SkillGUI from ovos_utils.events import EventContainer, EventSchedulerInterface from ovos_utils.intents import IntentServiceInterface from ovos_utils.process_utils import RuntimeRequirements @@ -66,8 +66,8 @@ def test_00_skill_init(self): self.assertIsInstance(self.skill.runtime_requirements, RuntimeRequirements) self.assertIsInstance(self.skill.voc_match_cache, dict) - self.assertTrue(self.skill._is_fully_initialized) - self.assertTrue(isdir(dirname(self.skill._settings_path))) + self.assertTrue(self.skill.is_fully_initialized) + self.assertTrue(isdir(dirname(self.skill.settings_path))) self.assertIsInstance(self.skill.settings, dict) self.assertIsNone(self.skill.dialog_renderer) self.assertIsInstance(self.skill.enclosure, EnclosureAPI) @@ -79,15 +79,15 @@ def test_00_skill_init(self): self.assertIsInstance(self.skill.location_timezone, str) self.assertIsInstance(self.skill.lang, str) self.assertEqual(len(self.skill.lang.split('-')), 2) - self.assertEqual(self.skill._core_lang, self.skill.lang) - self.assertIsInstance(self.skill._secondary_langs, list) - self.assertIsInstance(self.skill._native_langs, list) - self.assertIn(self.skill._core_lang, self.skill._native_langs) - self.assertIsInstance(self.skill._alphanumeric_skill_id, str) - self.assertIsInstance(self.skill._resources, SkillResources) - self.assertEqual(self.skill._resources.language, self.skill.lang) - self.assertFalse(self.skill._stop_is_implemented) - self.assertFalse(self.skill._converse_is_implemented) + self.assertEqual(self.skill.core_lang, self.skill.lang) + self.assertIsInstance(self.skill.secondary_langs, list) + self.assertIsInstance(self.skill.native_langs, list) + self.assertIn(self.skill.core_lang, self.skill.native_langs) + self.assertIsInstance(self.skill.alphanumeric_skill_id, str) + self.assertIsInstance(self.skill.resources, SkillResources) + self.assertEqual(self.skill.resources.language, self.skill.lang) + self.assertFalse(self.skill.stop_is_implemented) + self.assertFalse(self.skill.converse_is_implemented) def test_handle_first_run(self): # TODO @@ -558,7 +558,7 @@ class GuiSkill(Mock): "legacy": False}} root_dir = join(dirname(__file__), "test_gui") - @patch("ovos_workshop.skills.base.GUIInterface.__init__") + @patch("ovos_workshop.skills.ovos.GUIInterface.__init__") def test_skill_gui(self, interface_init): from ovos_utils.gui import GUIInterface from ovos_workshop.skills.base import SkillGUI diff --git a/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py index f0962fed..12ce8a42 100644 --- a/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py +++ b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill.py @@ -461,7 +461,7 @@ def test_voc_list(self): s = SimpleSkill1() s.root_dir = abspath(dirname(__file__)) - self.assertEqual(s._voc_list("turn_off_test"), + self.assertEqual(s.voc_list("turn_off_test"), ["turn off", "switch off"]) cache_key = s.lang + "turn_off_test" self.assertIn(cache_key, s._voc_cache) @@ -522,10 +522,10 @@ def test_native_langs(self): s.config_core['secondary_langs'] = ['en', 'en-us', 'en-AU', 'es', 'pt-PT'] self.assertEqual(s.lang, 'en-us') - self.assertEqual(s._secondary_langs, ['en', 'en-au', 'es', + self.assertEqual(s.secondary_langs, ['en', 'en-au', 'es', 'pt-pt']) - self.assertEqual(len(s._native_langs), len(set(s._native_langs))) - self.assertEqual(set(s._native_langs), {'en-us', 'en-au', 'pt-pt'}) + self.assertEqual(len(s.native_langs), len(set(s.native_langs))) + self.assertEqual(set(s.native_langs), {'en-us', 'en-au', 'pt-pt'}) s.config_core['lang'] = lang s.config_core['secondary_langs'] = secondary diff --git a/test/unittests/skills/test_mycroft_skill/test_mycroft_skill_get_response.py b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill_get_response.py index 9924fca9..279c48f0 100644 --- a/test/unittests/skills/test_mycroft_skill/test_mycroft_skill_get_response.py +++ b/test/unittests/skills/test_mycroft_skill/test_mycroft_skill_get_response.py @@ -164,11 +164,11 @@ def test_converse_detection(self): def validator(*args, **kwargs): self.assertTrue(skill._converse_is_implemented) - self.assertFalse(skill._converse_is_implemented) + self.assertFalse(skill.converse_is_implemented) skill.get_response('what do you want', validator=validator) skill._wait_response.assert_called_with(AnyCallable(), validator, AnyCallable(), -1) - self.assertFalse(skill._converse_is_implemented) + self.assertFalse(skill.converse_is_implemented) class TestMycroftSkillAskYesNo(TestCase): @@ -230,7 +230,7 @@ def test_ask_yesno_other(self): response = skill.ask_yesno('Do you like breakfast') self.assertEqual(response, 'I am a fish') - @mock.patch('ovos_workshop.skills.base.dig_for_message') + @mock.patch('ovos_workshop.skills.ovos.dig_for_message') def test_ask_yesno_german(self, dig_mock): """Check that when the skill is set to german it responds to "ja".""" # lang is session based, it comes from originating message in ovos-core diff --git a/test/unittests/test_abstract_app.py b/test/unittests/test_abstract_app.py index 1cf65145..d5fe4d7a 100644 --- a/test/unittests/test_abstract_app.py +++ b/test/unittests/test_abstract_app.py @@ -50,19 +50,20 @@ def test_gui_init(self): self.assertEqual(self.app.gui, self.gui) def test_settings_path(self): - self.assertIn("/apps/", self.app._settings_path) + self.assertIn("/apps/", self.app.settings_path) # Test settings path conflicts test_app = OVOSAbstractApplication(skill_id="test", bus=self.bus) - from ovos_workshop.skills import OVOSSkill, MycroftSkill + from ovos_workshop.skills import OVOSSkill + from ovos_workshop.skills.mycroft_skill import MycroftSkill test_skill = OVOSSkill(skill_id="test", bus=self.bus) mycroft_skill = MycroftSkill(skill_id="test", bus=self.bus) # Test app vs skill base directories - self.assertIn("/apps/", test_app._settings_path) - self.assertIn("/skills/", test_skill._settings_path) - self.assertEqual(test_skill._settings_path, - mycroft_skill._settings_path) + self.assertIn("/apps/", test_app.settings_path) + self.assertIn("/skills/", test_skill.settings_path) + self.assertEqual(test_skill.settings_path, + mycroft_skill.settings_path) self.assertEqual(test_skill.settings.path, mycroft_skill.settings.path) self.assertEqual(test_skill.settings, mycroft_skill.settings) @@ -74,8 +75,8 @@ def test_settings_path(self): self.assertFalse(test_app.settings['is_skill']) # Cleanup test files - remove(test_app._settings_path) - remove(test_skill._settings_path) + remove(test_app.settings_path) + remove(test_skill.settings_path) @patch("ovos_workshop.app.OVOSSkill.default_shutdown") def test_default_shutdown(self, skill_shutdown): @@ -107,5 +108,5 @@ def test_class_inheritance(self): self.assertIsInstance(self.app, BaseSkill) self.assertIsInstance(self.app, OVOSSkill) - self.assertIsInstance(self.app, MycroftSkill) + self.assertNotIsInstance(self.app, MycroftSkill) self.assertIsInstance(self.app, OVOSAbstractApplication)