From 81b0a1bed4099a62f9adff8917dd32e70c7fe480 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Fri, 29 Dec 2023 15:14:01 -0800 Subject: [PATCH 01/19] Update for ovos-workshop compat (#495) * Update ovos-workshop dependency Resolve and patch deprecation warnings * Remove deprecated patches Add log to troubleshoot test failure * Fix skill settings init error --------- Co-authored-by: Daniel McKnight --- neon_utils/skills/mycroft_skill.py | 13 -------- neon_utils/skills/neon_skill.py | 51 +++++++++++------------------- requirements/requirements.txt | 2 +- 3 files changed, 19 insertions(+), 47 deletions(-) diff --git a/neon_utils/skills/mycroft_skill.py b/neon_utils/skills/mycroft_skill.py index 9f0023ae..4e9c066b 100644 --- a/neon_utils/skills/mycroft_skill.py +++ b/neon_utils/skills/mycroft_skill.py @@ -57,14 +57,6 @@ def __init__(self, name=None, bus=None, *args, **kwargs): self._speak_timeout = 30 self._get_response_timeout = 15 # 10 for listener, 5 for STT, then timeout - @property - def settings_path(self): - # TODO: Deprecate backwards-compat. wrapper after ovos-workshop 0.0.13 - try: - return super().settings_path - except AttributeError: - return super()._settings_path - @property def location(self): """ @@ -90,11 +82,6 @@ def _init_settings(self): json.dump(self.settings, f, indent=4) self._initial_settings = dict(self.settings) - def _init_settings_manager(self): - # TODO: Same as upstream implementation? - from ovos_workshop.settings import SkillSettingsManager - self.settings_manager = SkillSettingsManager(self) - def _read_default_settings(self): yaml_path = os.path.join(self.root_dir, "settingsmeta.yml") json_path = os.path.join(self.root_dir, "settingsmeta.json") diff --git a/neon_utils/skills/neon_skill.py b/neon_utils/skills/neon_skill.py index 93c03828..1054ce4d 100644 --- a/neon_utils/skills/neon_skill.py +++ b/neon_utils/skills/neon_skill.py @@ -106,22 +106,6 @@ def initialize(self): self.schedule_event(self._write_cache_on_disk, CACHE_TIME_OFFSET, name="neon.load_cache_on_disk") - @property - def settings_path(self): - # TODO: Deprecate backwards-compat. wrapper after ovos-workshop 0.0.13 - try: - return super().settings_path - except AttributeError: - return super()._settings_path - - @property - def resources(self): - # TODO: Deprecate backwards-compat. wrapper after ovos-workshop 0.0.13 - try: - return super().resources - except AttributeError: - return super()._resources - @property # @deprecated("Call `dateutil.tz.gettz` directly", "2.0.0") def sys_tz(self): @@ -545,23 +529,24 @@ def _init_settings(self): Extends the default method to handle settingsmeta defaults locally """ from neon_utils.configuration_utils import dict_update_keys - super()._init_settings() - skill_settings = get_local_settings(self.settings_path) - settings_from_disk = dict(skill_settings) - self.settings = dict_update_keys(skill_settings, - self._read_default_settings()) - if self.settings != settings_from_disk: - if isinstance(self.settings, JsonStorage): - self.settings.store() - else: - with open(self.settings_path, "w+") as f: - json.dump(self.settings, f, indent=4) - self._initial_settings = dict(self.settings) - - def _init_settings_manager(self): - # TODO: Same as upstream implementation? - from ovos_workshop.settings import SkillSettingsManager - self.settings_manager = SkillSettingsManager(self) + BaseSkill._init_settings(self) + settings_from_disk = dict(self.settings) + dict_update_keys(self._settings, self._read_default_settings()) + if self._settings != settings_from_disk: + LOG.info("Updated default settings from skill metadata") + self._settings.store() + self._initial_settings = dict(self._settings) + LOG.info(f"Skill initialized with settings: {self.settings}") + + def _handle_converse_request(self, message: Message): + # TODO: Remove patch after ovos-core 0.0.8 + if message.msg_type == "skill.converse.request" and \ + message.data.get('skill_id') != self.skill_id: + # Legacy request not for Neon + return + if message.msg_type == "skill.converse.request": + message.msg_type = "neon.converse.request" + BaseSkill._handle_converse_request(self, message) def _read_default_settings(self): from neon_utils.configuration_utils import parse_skill_default_settings diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 7b7a2675..1dd7d085 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -8,4 +8,4 @@ ovos-lingua-franca~=0.4 ovos_utils~=0.0.35 geopy~=2.1 ovos-config~=0.0.9 -ovos-workshop~=0.0.12 \ No newline at end of file +ovos-workshop~=0.0.15 \ No newline at end of file From 2d1f82e5ae2423cec16b0241fb6d7612fa8f0cde Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Fri, 29 Dec 2023 23:14:19 +0000 Subject: [PATCH 02/19] Increment Version to 1.8.3a1 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index c6ad4662..0fae0a55 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "1.8.2" +__version__ = "1.8.3a1" From c557e110ba0aada263c04b0109a147cee11b5479 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Fri, 29 Dec 2023 23:15:07 +0000 Subject: [PATCH 03/19] Update Changelog --- CHANGELOG.md | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 249e4796..49c35feb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,12 @@ # Changelog -## [1.8.2a2](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.2a2) (2023-12-27) +## [1.8.3a1](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a1) (2023-12-29) -[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.2a1...1.8.2a2) +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.2...1.8.3a1) **Merged pull requests:** -- Use OpenStreetMap geocoder to work around maps.co API changes [\#493](https://github.com/NeonGeckoCom/neon-utils/pull/493) ([NeonDaniel](https://github.com/NeonDaniel)) - -## [1.8.2a1](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.2a1) (2023-12-20) - -[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.1...1.8.2a1) - -**Merged pull requests:** - -- Better default profile handling [\#492](https://github.com/NeonGeckoCom/neon-utils/pull/492) ([NeonDaniel](https://github.com/NeonDaniel)) +- Update for ovos-workshop compat [\#495](https://github.com/NeonGeckoCom/neon-utils/pull/495) ([NeonDaniel](https://github.com/NeonDaniel)) From 56d3f23d5a5ef7f7a07a094c7a8d1abb9b2b3a3b Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Tue, 2 Jan 2024 13:13:50 -0800 Subject: [PATCH 04/19] Refactor and Deprecate NeonFallbackSkill (#496) * Mark `NeonFallbackSkill` as deprecated Update `NeonSkill` to directly extend `OVOSSkill` instead of using wrapped reference * Troubleshooting FallbackSkill init * More fallback init troubleshooting * Fix unresolved import * Fix refactor in `NeonSkill` Refactor `NeonFallbackSkill` to not extend `NeonSkill` directly * Update tests to account for FallbackSkill refactor --------- Co-authored-by: Daniel McKnight --- neon_utils/skills/mycroft_skill.py | 2 +- neon_utils/skills/neon_fallback_skill.py | 884 ++++++++++++++++++++++- neon_utils/skills/neon_skill.py | 12 +- tests/neon_skill_tests.py | 2 +- 4 files changed, 854 insertions(+), 46 deletions(-) diff --git a/neon_utils/skills/mycroft_skill.py b/neon_utils/skills/mycroft_skill.py index 4e9c066b..d4cb7961 100644 --- a/neon_utils/skills/mycroft_skill.py +++ b/neon_utils/skills/mycroft_skill.py @@ -31,7 +31,7 @@ import os.path import yaml -from threading import Event, Thread +from threading import Event from typing import Optional from json_database import JsonStorage from ovos_bus_client.message import Message diff --git a/neon_utils/skills/neon_fallback_skill.py b/neon_utils/skills/neon_fallback_skill.py index d8f7b8f0..4a9a88d6 100644 --- a/neon_utils/skills/neon_fallback_skill.py +++ b/neon_utils/skills/neon_fallback_skill.py @@ -25,59 +25,867 @@ # LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -from ovos_utils import LOG +import json +import os +import pathlib +import pickle +import time +from copy import deepcopy +from functools import wraps +from threading import Event +from typing import List, Any, Optional -from neon_utils.skills.neon_skill import NeonSkill -from ovos_utils.intents import IntentLayers -from ovos_workshop.decorators.layers import IntentLayers +import yaml +from dateutil.tz import gettz +from json_database import JsonStorage +from neon_mq_connector.utils.client_utils import send_mq_request +from ovos_bus_client import Message +from ovos_plugin_manager.language import OVOSLangDetectionFactory, OVOSLangTranslationFactory +from ovos_utils.gui import is_gui_connected +from ovos_utils.log import LOG, log_deprecation, deprecated +from ovos_utils.skills import get_non_properties +from ovos_utils.skills.settings import save_settings +from ovos_utils.xdg_utils import xdg_cache_home +from ovos_workshop.skills import OVOSSkill from ovos_workshop.skills.fallback import FallbackSkillV1 +from neon_utils.cache_utils import LRUCache +from neon_utils.file_utils import resolve_neon_resource_file +from neon_utils.location_utils import to_system_time +from neon_utils.message_utils import dig_for_message, resolve_message, get_message_user +from neon_utils.skills.neon_skill import CACHE_TIME_OFFSET, DEFAULT_SPEED_MODE, SPEED_MODE_EXTENSION_TIME +from neon_utils.user_utils import get_user_prefs -# TODO: Consider deprecation and implementing ovos_workshop directly -class NeonFallbackSkill(FallbackSkillV1, NeonSkill): + +class NeonFallbackSkill(FallbackSkillV1): """ Class that extends the NeonSkill and FallbackSkill classes to provide NeonSkill functionality to any Fallback skill subclassing this class. """ def __init__(self, *args, **kwargs): - # Manual init of OVOSSkill - self.private_settings = None - self._threads = [] - self._original_converse = self.converse - self.intent_layers = IntentLayers() - self.audio_service = None - - # Manual init of FallbackSkill - # list of fallback handlers registered by this instance - self.instance_fallback_handlers = [] - NeonSkill.__init__(self, *args, **kwargs) + log_deprecation("This class is deprecated. Implement " + "`ovos_workshop.skills.fallback.FallbackSkill`", + "2.0.0") + + FallbackSkillV1.__init__(self, *args, **kwargs) LOG.debug(f"instance_handlers={self.instance_fallback_handlers}") LOG.debug(f"class_handlers={FallbackSkillV1.fallback_handlers}") + # Manual init of NeonSkill + self.cache_loc = os.path.join(xdg_cache_home(), "neon") + os.makedirs(self.cache_loc, exist_ok=True) + self.lru_cache = LRUCache() + self._gui_connected = False + + try: + import neon_core + self._neon_core = True + except ImportError: + self._neon_core = False + + self._actions_to_confirm = dict() + + self._lang_detector = None + self._translator = None + + # TODO: Should below defaults be global config? + # allow skills to specify timeout overrides per-skill + self._speak_timeout = 30 + self._get_response_timeout = 15 # 10 for listener, 5 for STT, then timeout + + def initialize(self): + # schedule an event to load the cache on disk every CACHE_TIME_OFFSET seconds + self.schedule_event(self._write_cache_on_disk, CACHE_TIME_OFFSET, + name="neon.load_cache_on_disk") + + @property + # @deprecated("Call `dateutil.tz.gettz` directly", "2.0.0") + def sys_tz(self): + # TODO: Is this deprecated? + return gettz() + + @property + @deprecated("Nothing should depend on `neon_core` vs other cores", "2.0.0") + def neon_core(self): + return self._neon_core + + @property + @deprecated("Skills should track this internally or use converse", + "2.0.0") + def actions_to_confirm(self) -> dict: + return self._actions_to_confirm + + @actions_to_confirm.setter + @deprecated("Skills should track this internally or use converse", + "2.0.0") + def actions_to_confirm(self, val: dict): + self._actions_to_confirm = val + + @property + def lang_detector(self): + if not self._lang_detector and OVOSLangDetectionFactory: + try: + self._lang_detector = \ + OVOSLangDetectionFactory.create(self.config_core) + except ValueError as x: + LOG.error(f"Configured lang plugins not available: {x}") + return self._lang_detector + + @property + def translator(self): + if not self._translator and OVOSLangTranslationFactory: + try: + self._translator = \ + OVOSLangTranslationFactory.create(self.config_core) + except ValueError as x: + LOG.error(f"Configured lang plugins not available: {x}") + return self._translator + + @property + @deprecated("This is now configured in CommonQuery", "2.0.0") + def skill_mode(self) -> str: + """ + Determine the "speed mode" requested by the user + """ + return get_user_prefs(dig_for_message()).get( + 'response_mode', {}).get('speed_mode') or DEFAULT_SPEED_MODE + + @property + @deprecated("This is now configured in CommonQuery", "2.0.0") + def extension_time(self) -> int: + """ + Determine how long the skill should extend CommonSkill request timeouts + """ + return SPEED_MODE_EXTENSION_TIME.get(self.skill_mode) or 10 + + @property + @deprecated("Always emit GUI events for the GUI module to manage", "2.0.0") + def gui_enabled(self) -> bool: + """ + If True, skill should display GUI pages + """ + try: + self._gui_connected = self._gui_connected or \ + is_gui_connected(self.bus) + return self._gui_connected + except Exception as x: + # In container environments, this check fails so assume True + LOG.exception(x) + return True + @property - def fallback_config(self): - # "skill_id": priority (int) overrides - return self.config_core["skills"].get("fallbacks", {}) + @deprecated("reference `self.settings` directly", "2.0.0") + def ngi_settings(self): + return self.preference_skill() + + @deprecated("reference `self.settings` directly", "2.0.0") + def preference_skill(self, message=None) -> dict: + """ + Returns the skill settings configuration + Equivalent to self.settings if settings not in message context + :param message: Message associated with request + :return: dict of skill preferences + """ + message = message or dig_for_message() + return get_user_prefs( + message).get("skills", {}).get(self.skill_id) or self.settings + + @deprecated("implement `neon_utils.user_utils.update_user_profile`", + "2.0.0") + def update_profile(self, new_preferences: dict, message: Message = None): + """ + Updates a user profile with the passed new_preferences + :param new_preferences: dict of updated preference values. + Should follow {section: {key: val}} format + :param message: Message associated with request + """ + from neon_utils.user_utils import update_user_profile + message = message or dig_for_message() + try: + update_user_profile(new_preferences, message, self.bus) + except Exception as x: + LOG.error(x) + + @resolve_message + def update_skill_settings(self, new_preferences: dict, + message: Message = None, skill_global=True): + """ + Updates skill settings with the passed new_preferences + :param new_preferences: dict of updated preference values. {key: val} + :param message: Message associated with request + :param skill_global: Boolean to indicate these are + global/non-user-specific variables + """ + # TODO: Spec how to handle global vs per-user settings + LOG.debug(f"Update skill settings with new: {new_preferences}") + new_settings = {**self.preference_skill(message), **new_preferences} + if not skill_global: + new_preferences["skill_id"] = self.skill_id + self.update_profile({"skills": {self.skill_id: new_settings}}, + message) + else: + self.settings = new_settings + if isinstance(self.settings, JsonStorage): + self.settings.store() + else: + save_settings(self.file_system.path, self.settings) + + def send_with_audio(self, text_shout, audio_file, message, lang="en-us", + private=False, speaker=None): + """ + Sends a Neon response with the passed text phrase and audio file + :param text_shout: (str) Text to shout + :param audio_file: (str) Full path to an arbitrary audio file to + attach to shout; must be readable/accessible + :param message: Message associated with request + :param lang: (str) Language of wav_file + :param private: (bool) Whether or not shout is private to the user + :param speaker: (dict) Message sender data + """ + # TODO: Update 'speak' to handle audio files + # from shutil import copyfile + if not speaker: + speaker = {"name": "Neon", "language": None, "gender": None, + "voice": None} + + # Play this back regardless of user prefs + speaker["override_user"] = True + + # Either gender should be fine + responses = {lang: {"sentence": text_shout, + "male": audio_file, + "female": audio_file}} + message.context["private"] = private + LOG.info(f"sending klat.response with responses={responses} | " + f"speaker={speaker}") + self.bus.emit(message.forward("klat.response", + {"responses": responses, + "speaker": speaker})) + + @deprecated("implement `neon_utils.user_utils.neon_must_respond`", "2.0.0") + def neon_must_respond(self, message: Message = None) -> bool: + """ + Checks if Neon must respond to an utterance (i.e. a server request) + :param message: message associated with user request + :returns: True if Neon must provide a response to this request + """ + from neon_utils.message_utils import neon_must_respond + return neon_must_respond(message) + + def voc_match(self, utt, voc_filename, lang=None, exact=False): + # TODO: This should be addressed in vocab resolver classes + try: + super_return = super().voc_match(utt, voc_filename, lang, exact) + except FileNotFoundError: + super_return = None + lang = lang or self.lang + + if super_return: + return super_return + elif super_return is False: + if self.voc_match_cache.get(lang + voc_filename): + return super_return + + LOG.warning(f"`{voc_filename}` not found, checking in neon_core") + voc = resolve_neon_resource_file(f"text/{lang}/{voc_filename}.voc") + if not voc: + raise FileNotFoundError(voc) + from ovos_utils.file_utils import read_vocab_file + from itertools import chain + import re + vocab = read_vocab_file(voc) + cache_key = lang + voc_filename + self.voc_match_cache[cache_key] = list(chain(*vocab)) + if utt: + if exact: + # Check for exact match + return any(i.strip() == utt + for i in self.voc_match_cache[cache_key]) + else: + # Check for matches against complete words + return any([re.match(r'.*\b' + i + r'\b.*', utt) + for i in self.voc_match_cache[cache_key]]) + else: + return False + + @deprecated("WW status can be queried via messagebus", "2.0.0") + def neon_in_request(self, message: Message) -> bool: + """ + Checks if the utterance is intended for Neon. + Server utilizes current conversation, otherwise wake-word status + and message "Neon" parameter used + """ + if not self._neon_core: + return True + + from neon_utils.message_utils import request_for_neon + ww_enabled = self.config_core.get("listener", + {}).get("wake_word_enabled", True) + # TODO: Listen for WW state changes on the bus + return request_for_neon(message, "neon", self.voc_match, ww_enabled) + + def report_metric(self, name, data): + """Report a skill metric to the Mycroft servers. + + Arguments: + name (str): Name of metric. Must use only letters and hyphens + data (dict): JSON dictionary to report. Must be valid JSON + """ + combined = deepcopy(data) + combined["name"] = name + self.bus.emit(Message("neon.metric", combined)) + + def send_email(self, title, body, message=None, email_addr=None, + attachments=None): + """ + Send an email to the registered user's email. + Method here for backwards compatibility with Mycroft skills. + Email address priority: email_addr, user prefs from message, + fallback to DeviceApi for Mycroft method + + Arguments: + title (str): Title of email + body (str): HTML body of email. This supports + simple HTML like bold and italics + email_addr (str): Optional email address to send message to + attachments (dict): Optional dict of file names to Base64 encoded files + message (Message): Optional message to get email from + """ + message = message or dig_for_message() + if not email_addr and message: + email_addr = get_user_prefs(message)["user"].get("email") + + if email_addr and send_mq_request: + LOG.info("Send email via Neon Server") + request_data = {"recipient": email_addr, + "subject": title, + "body": body, + "attachments": attachments} + data = send_mq_request("/neon_emails", request_data, + "neon_emails_input") + return data.get("success") + else: + LOG.warning("Attempting to send email via Mycroft Backend") + super().send_email(title, body) + + def make_active(self, duration_minutes=5): + """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. + :param duration_minutes: duration in minutes for skill to remain active + (-1 for infinite) + """ + self.bus.emit(Message("active_skill_request", + {"skill_id": self.skill_id, + "timeout": duration_minutes})) + + def schedule_event(self, handler, when, data=None, name=None, context=None): + # TODO: should 'when' already be a datetime? DM + if isinstance(when, int) or isinstance(when, float): + from datetime import datetime as dt, timedelta + when = to_system_time(dt.now(self.sys_tz)) + timedelta(seconds=when) + LOG.debug(f"Made a datetime: {when}") + super().schedule_event(handler, when, data, name, context) + + @deprecated("This method is deprecated", "2.0.0") + def request_check_timeout(self, time_wait: int, + intent_to_check: List[str]): + """ + Set the specified intent to be disabled after the specified time + :param time_wait: Time in seconds to wait before deactivating intent + :param intent_to_check: list of intents to disable + """ + # TODO: Consider unit tests or deprecation of this method DM + LOG.debug(time_wait) + LOG.debug(intent_to_check) + if isinstance(intent_to_check, str): + LOG.warning(f"Casting string to list: {intent_to_check}") + intent_to_check = [intent_to_check] + + for intent in intent_to_check: + data = {'time_out': time_wait, + 'intent_to_check': f"{self.skill_id}:{intent}"} + LOG.debug(f"Set Timeout: {data}") + self.bus.emit(Message("set_timeout", data)) + + def update_cached_data(self, filename: str, new_element: Any): + """ + Updates a generic cache file + :param filename: filename of cache object to update (relative to cacheDir) + :param new_element: object to cache at passed location + """ + # TODO: Move to static function with XDG compat. + with open(os.path.join(self.cache_loc, filename), 'wb+') as file_to_update: + pickle.dump(new_element, file_to_update, protocol=pickle.HIGHEST_PROTOCOL) - @classmethod - def _register_fallback(cls, *args, **kwargs): - LOG.debug(f"register fallback") - FallbackSkillV1._register_fallback(*args, **kwargs) + def get_cached_data(self, filename: str, + file_loc: Optional[str] = None) -> dict: + """ + Retrieves cache data from a file created/updated with update_cached_data + :param filename: (str) filename of cache object to update + :param file_loc: (str) path to directory containing filename (defaults to cache dir) + :return: (dict) cache data + """ + # TODO: Move to static function with XDG compat. + if not file_loc: + file_loc = self.cache_loc + cached_location = os.path.join(file_loc, filename) + if pathlib.Path(cached_location).exists(): + with open(cached_location, 'rb') as file: + return pickle.load(file) + else: + return {} + + def decorate_api_call_use_lru(self, func): + """ + Decorate the API-call function to use LRUcache. + NOTE: the wrapper adds an additional argument, so decorated functions MUST be called with it! + + from wikipedia_for_humans import summary + summary = decorate_api_call_use_lru(summary) + result = summary(lru_query='neon', query='neon', lang='en') + + Args: + func: the function to be decorated + Returns: decorated function + """ + @wraps(func) + def wrapper(lru_query: str, *args, **kwargs): + # TODO might use an abstract method for cached API call to define a signature + result = self.lru_cache.get(lru_query) + if not result: + result = func(*args, **kwargs) + self.lru_cache.put(key=lru_query, value=result) + return result + return wrapper + + def _write_cache_on_disk(self): + """ + Write the cache on disk, reset the cache and reschedule the event. + This handler is enabled by scheduling an event in NeonSkill.initialize(). + Returns: + """ + filename = f"lru_{self.skill_id}" + data_load = self.lru_cache.jsonify() + self.update_cached_data(filename=filename, new_element=data_load) + self.lru_cache.clear() + self.schedule_event(self._write_cache_on_disk, CACHE_TIME_OFFSET, name="neon.load_cache_on_disk") + return + + def _register_chat_handler(self, name: str, method: callable): + """ + Register a chat handler entrypoint. Decorated methods must + return a string response that will be emitted as a response to the + incoming Message. + :param name: name of the chat handler + :param method: method to handle incoming chat Messages + """ + + def wrapped_handler(message): + response = method(message) + self.bus.emit(message.response(data={'response': response}, + context={'skill_id': self.skill_id})) + + self.add_event(f'chat.{name}', wrapped_handler) + msg = dig_for_message() or Message("", + context={'skill_id': self.skill_id}) + self.bus.emit(msg.forward("register_chat_handler", {'name': name})) def _register_decorated(self): - # Explicitly overridden to ensure the correct super call is made - LOG.debug(f"Registering decorated methods for {self.skill_id}") + 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, 'chat_handler'): + self._register_chat_handler(getattr(method, 'chat_handler'), + method) + + @property + def location(self): + """ + Backwards-compatible location property. Returns core location config if + user location isn't specified. + """ + from neon_utils.configuration_utils import get_mycroft_compatible_location + return get_mycroft_compatible_location(get_user_prefs()["location"]) + + def _init_settings(self): + """ + Extends the default method to handle settingsmeta defaults locally + """ + from neon_utils.configuration_utils import dict_update_keys + OVOSSkill._init_settings(self) + settings_from_disk = dict(self.settings) + dict_update_keys(self._settings, self._read_default_settings()) + if self._settings != settings_from_disk: + LOG.info("Updated default settings from skill metadata") + self._settings.store() + self._initial_settings = dict(self._settings) + LOG.info(f"Skill initialized with settings: {self.settings}") + + def _handle_converse_request(self, message: Message): + # TODO: Remove patch after ovos-core 0.0.8 + if message.msg_type == "skill.converse.request" and \ + message.data.get('skill_id') != self.skill_id: + # Legacy request not for Neon + return + if message.msg_type == "skill.converse.request": + message.msg_type = "neon.converse.request" + OVOSSkill._handle_converse_request(self, message) + + def _read_default_settings(self): + from neon_utils.configuration_utils import parse_skill_default_settings + yaml_path = os.path.join(self.root_dir, "settingsmeta.yml") + json_path = os.path.join(self.root_dir, "settingsmeta.json") + if os.path.isfile(yaml_path): + with open(yaml_path) as f: + self.settings_meta = yaml.safe_load(f) or dict() + elif os.path.isfile(json_path): + with open(json_path) as f: + self.settings_meta = json.load(f) + else: + return dict() + return parse_skill_default_settings(self.settings_meta) + + @resolve_message + def speak(self, utterance, expect_response=False, wait=False, meta=None, + message=None, private=False, speaker=None): + """ + Speak an utterance. + Arguments: + 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. + message (Message): message associated with the input that this speak is associated with + private (bool): flag to indicate this message contains data that is private to the requesting user + speaker (dict): dict containing language or voice data to override user preference values + + """ + from neon_utils.signal_utils import check_for_signal, wait_for_signal_clear + # registers the skill as being active + meta = meta or {} + meta['skill'] = self.name + self.enclosure.register(self.name) + if utterance: + if not message: + LOG.debug('message is None.') + message = Message("speak") + if not speaker: + speaker = message.data.get("speaker", None) + + nick = get_message_user(message) + + if private and message.context.get("klat_data"): + LOG.debug("Private Message") + title = message.context["klat_data"].get("title") or \ + "!PRIVATE:Neon" + need_at_sign = True + if title.startswith("!PRIVATE"): + users = title.split(':')[1].split(',') + for idx, val in enumerate(users): + users[idx] = val.strip() + if len(users) == 2 and "Neon" in users: + need_at_sign = False + elif len(users) == 1: + need_at_sign = False + elif nick.startswith("guest"): + need_at_sign = False + if need_at_sign: + LOG.debug("Send message to private cid!") + utterance = f"@{nick} {utterance}" + + data = {"utterance": utterance, + "lang": self.lang, + "expect_response": expect_response, + "meta": meta, + "speaker": speaker, + "speak_ident": str(time.time())} + + if message.context.get("cc_data", {}).get("emit_response"): + msg_to_emit = message.reply("skills:execute.response", data, + {"destination": ["skills"], + "source": ["skills"]}) + else: + message.context.get("timing", {})["speech_start"] = time.time() + msg_to_emit = message.reply("speak", data, + {"destination": ["skills"], + "source": ["audio"]}) + LOG.debug(f"Skill speak! {data}") + LOG.debug(msg_to_emit.msg_type) + + if wait and check_for_signal("neon_speak_api", -1): + self.bus.wait_for_response(msg_to_emit, + msg_to_emit.data['speak_ident'], + self._speak_timeout) + else: + self.bus.emit(msg_to_emit) + if wait and not message.context.get("klat_data"): + LOG.debug("Using legacy isSpeaking signal") + wait_for_signal_clear('isSpeaking') + + else: + LOG.warning("Null utterance passed to speak") + LOG.warning(f"{self.name} | message={message}") + + @resolve_message + def speak_dialog(self, key, data=None, expect_response=False, wait=False, + message=None, private=False, speaker=None): + """ Speak a random sentence from a dialog file. + + Arguments: + :param key: dialog file key (e.g. "hello" to speak from the file + "locale/en-us/hello.dialog") + :param data: information used to populate key + :param expect_response: set to True if Mycroft should listen for a + response immediately after speaking. + :param wait: set to True to block while the text is being spoken. + :param message: associated message from request + :param private: private flag (server use only) + :param speaker: optional dict of speaker info to use + """ + data = data or {} + LOG.debug(f"data={data}") + if self.dialog_renderer: # TODO: Pass index (0) here to use non-random responses DM + to_speak = self.dialog_renderer.render(key, data) + else: + to_speak = key + self.speak(to_speak, + expect_response, message=message, private=private, + speaker=speaker, wait=wait, meta={'dialog': key, + 'data': data}) + + @resolve_message + def get_response(self, dialog: str = '', data: Optional[dict] = None, + validator=None, on_fail=None, num_retries: int = -1, + message: Optional[Message] = None) -> Optional[str]: + """ + Gets a response from a user. Speaks the passed dialog file or string + and then optionally plays a listening confirmation sound and + starts listening if in wake words mode. + Wraps the default Mycroft method to add support for multiple users and + running without a wake word. + + Arguments: + dialog (str): Optional dialog to speak to the user + data (dict): Data used to render the dialog + validator (any): Function with following signature + def validator(utterance): + return utterance != "red" + on_fail (any): Dialog or function returning literal string + to speak on invalid input. For example: + def on_fail(utterance): + return "nobody likes the color red, pick another" + num_retries (int): Times to ask user for input, -1 for infinite + NOTE: User can not respond and timeout or say "cancel" to stop + message (Message): Message associated with request + + Returns: + str: User's reply or None if timed out or canceled + """ + user = get_message_user(message) or "local" if message else "local" + data = data or {} + + def on_fail_default(utterance): + fail_data = data.copy() + fail_data['utterance'] = utterance + + if on_fail: + to_speak = on_fail + else: + to_speak = dialog + if self.dialog_renderer: + return self.dialog_renderer.render(to_speak, data) + else: + return to_speak + + 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 + + # Ensure we have a message to forward + message = message or dig_for_message() + if not message: + LOG.warning(f"Could not locate message associated with request!") + message = Message("get_response") + + # If skill has dialog, render the input + if self.dialog_renderer: + dialog = self.dialog_renderer.render(dialog, data) + + if dialog: + self.speak(dialog, wait=True, message=message, private=True) + self.bus.emit(message.forward('mycroft.mic.listen')) + return self._wait_response(is_cancel, validator, on_fail_fn, + num_retries, message, user) + + def _wait_response(self, is_cancel, validator, on_fail, num_retries, + message=None, user: str = None): + """ + 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 (callable): function checking for a valid response + on_fail (callable): function handling retries + message (Message): message associated with request + """ + user = user or "local" + num_fails = 0 + while True: + response = self.__get_response(user) + + if response is None: # No Response + # if nothing said, only prompt one more time + num_none_fails = 1 if num_retries < 0 else num_retries + LOG.debug(f"num_none_fails={num_none_fails}|" + f"num_fails={num_fails}") + if num_fails >= num_none_fails: + LOG.info("No user response") + return None + else: # Some response + # catch user saying 'cancel' + if is_cancel(response): + LOG.info("User cancelled") + return None + validated = validator(response) + # returns the validated value or the response + # (backwards compat) + if validated is not False and validated is not None: + LOG.debug(f"Returning validated response") + return response if validated is True else validated + LOG.debug(f"User response not validated: {response}") + # Unvalidated or no response + num_fails += 1 + if 0 < num_retries < num_fails: + LOG.info(f"Failed ({num_fails}) through all retries " + f"({num_retries})") + return None + + # Validation failed, retry + line = on_fail(response) + if line: + LOG.debug(f"Speaking failure dialog: {line}") + self.speak(line, wait=True, message=message, private=True) + + LOG.debug("Listen for another response") + msg = message.reply('mycroft.mic.listen') or \ + Message('mycroft.mic.listen', + context={"skill_id": self.skill_id}) + self.bus.emit(msg) + + def __get_response(self, user="local"): + """ + Helper to get a response from the user + + Arguments: + user (str): user associated with response + Returns: + str: user's response or None on a timeout + """ + event = Event() + + def converse(message): + resp_user = get_message_user(message) or "local" + if resp_user == user: + utterances = message.data.get("utterances") + converse.response = utterances[0] if utterances else None + event.set() + LOG.info(f"Got response: {converse.response}") + return True + LOG.debug(f"Ignoring input from: {resp_user}") + return False + + # install a temporary conversation handler + self.make_active() + converse.response = None + default_converse = self.converse + self.converse = converse + + if not event.wait(self._get_response_timeout): + LOG.warning("Timed out waiting for user response") + self.converse = default_converse + return converse.response + + # 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`", "2.0.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`", "2.0.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`", "2.0.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 self.resources.template_file(text, data) instead + """ + log_deprecation("Use `resources.template_file`", "2.0.0") + return self.resources.load_template_file(template_name, data) + + def init_dialog(self, root_directory: Optional[str] = None): + """ + DEPRECATED: use load_dialog_files instead + """ + log_deprecation("Use `load_dialog_files`", "2.0.0") + self.load_dialog_files(root_directory) + + def add_event(self, name: str, handler: callable, + handler_info: Optional[str] = None, once: bool = False, + speak_errors: bool = True): + # TODO: Remove with ovos-workshop==0.0.13 try: - FallbackSkillV1._register_decorated(self) + # Patching FakeBus compat. with MessageBusClient + if hasattr(self.bus, "ee"): + emitter = self.bus.ee + else: + emitter = self.bus.emitter + if handler_info == "mycroft.skill.handler" and \ + emitter.listeners(name): + LOG.warning(f"Not re-registering intent handler {name}") + return except Exception as e: - LOG.error(e) - NeonSkill._register_decorated(self) - from ovos_utils.skills import get_non_properties - for attr_name in get_non_properties(self): - method = getattr(self, attr_name) - if hasattr(method, 'fallback_priority'): - self.register_fallback(method, method.fallback_priority) - - def register_fallback(self, *args, **kwargs): - LOG.debug(f"Registering fallback handler for {self.skill_id}") - FallbackSkillV1.register_fallback(self, *args, **kwargs) + LOG.exception(e) + OVOSSkill.add_event(self, name, handler, handler_info, once, + speak_errors) + diff --git a/neon_utils/skills/neon_skill.py b/neon_utils/skills/neon_skill.py index 1054ce4d..572f72f9 100644 --- a/neon_utils/skills/neon_skill.py +++ b/neon_utils/skills/neon_skill.py @@ -52,7 +52,7 @@ from neon_utils.cache_utils import LRUCache from neon_utils.file_utils import resolve_neon_resource_file from neon_utils.user_utils import get_user_prefs -from ovos_workshop.skills.base import BaseSkill +from ovos_workshop.skills.ovos import OVOSSkill try: from neon_utils.mq_utils import send_mq_request @@ -77,9 +77,9 @@ CACHE_TIME_OFFSET = 24*60*60 # seconds in 24 hours -class NeonSkill(BaseSkill): +class NeonSkill(OVOSSkill): def __init__(self, name=None, bus=None, **kwargs): - BaseSkill.__init__(self, name, bus, **kwargs) + OVOSSkill.__init__(self, name, bus, **kwargs) self.cache_loc = os.path.join(xdg_cache_home(), "neon") os.makedirs(self.cache_loc, exist_ok=True) self.lru_cache = LRUCache() @@ -529,7 +529,7 @@ def _init_settings(self): Extends the default method to handle settingsmeta defaults locally """ from neon_utils.configuration_utils import dict_update_keys - BaseSkill._init_settings(self) + OVOSSkill._init_settings(self) settings_from_disk = dict(self.settings) dict_update_keys(self._settings, self._read_default_settings()) if self._settings != settings_from_disk: @@ -546,7 +546,7 @@ def _handle_converse_request(self, message: Message): return if message.msg_type == "skill.converse.request": message.msg_type = "neon.converse.request" - BaseSkill._handle_converse_request(self, message) + OVOSSkill._handle_converse_request(self, message) def _read_default_settings(self): from neon_utils.configuration_utils import parse_skill_default_settings @@ -894,5 +894,5 @@ def add_event(self, name: str, handler: callable, return except Exception as e: LOG.exception(e) - BaseSkill.add_event(self, name, handler, handler_info, once, + OVOSSkill.add_event(self, name, handler, handler_info, once, speak_errors) diff --git a/tests/neon_skill_tests.py b/tests/neon_skill_tests.py index 793d1859..a2c48f3b 100644 --- a/tests/neon_skill_tests.py +++ b/tests/neon_skill_tests.py @@ -121,7 +121,7 @@ def test_common_query_skill_init(self): def test_fallback_skill_init(self): skill = create_skill(TestFBS) # self.assertIsInstance(skill, MycroftSkill) - self.assertIsInstance(skill, NeonSkill) + # self.assertIsInstance(skill, NeonSkill) self.assertIsInstance(skill, NeonFallbackSkill) # self.assertIsInstance(skill, FallbackSkill) self.assertEqual(skill.name, "Test Fallback Skill") From bff8e0577a0c3656d1caf972560e410665397cdd Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Tue, 2 Jan 2024 21:14:05 +0000 Subject: [PATCH 05/19] Increment Version to 1.8.3a2 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 0fae0a55..23abe1bf 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "1.8.3a1" +__version__ = "1.8.3a2" From 3cd6c643d00109a54d41ea692198d024cc1ac321 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Tue, 2 Jan 2024 21:14:49 +0000 Subject: [PATCH 06/19] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49c35feb..ae8b04ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.8.3a2](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a2) (2024-01-02) + +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a1...1.8.3a2) + +**Merged pull requests:** + +- Refactor and Deprecate NeonFallbackSkill [\#496](https://github.com/NeonGeckoCom/neon-utils/pull/496) ([NeonDaniel](https://github.com/NeonDaniel)) + ## [1.8.3a1](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a1) (2023-12-29) [Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.2...1.8.3a1) From 4389594adeb96ae7ed6185c476c72db18f1f2a96 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Tue, 23 Jan 2024 18:23:31 -0800 Subject: [PATCH 07/19] Add utilities for interfacing with Neon HANA (#497) * Add `hana_utils` with unit tests Update unit tests to test entire module in one step * Revert bad unit test automation refactor * Fix bad refactor * Refactor tests to minimize auth requests * Refactor token logic to internal methods to ensure stable API * Refactor to better support `server` configuration * Refactor to skip Configuration parsing * Update patch missed in refactor --------- Co-authored-by: Daniel McKnight --- .github/workflows/unit_tests.yml | 9 +++ neon_utils/hana_utils.py | 132 +++++++++++++++++++++++++++++++ tests/hana_util_tests.py | 128 ++++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+) create mode 100644 neon_utils/hana_utils.py create mode 100644 tests/hana_util_tests.py diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 2c560818..bea57b72 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -224,3 +224,12 @@ jobs: with: name: language-util-test-results path: tests/language-util-test-results.xml + + - name: Test Hana Utils + run: | + pytest tests/hana_util_tests.py --doctest-modules --junitxml=tests/hana-util-test-results.xml + - name: Upload hana utils test results + uses: actions/upload-artifact@v2 + with: + name: hana-util-test-results + path: tests/hana-util-test-results.xml \ No newline at end of file diff --git a/neon_utils/hana_utils.py b/neon_utils/hana_utils.py new file mode 100644 index 00000000..9fb788ee --- /dev/null +++ b/neon_utils/hana_utils.py @@ -0,0 +1,132 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +import requests +import json + +from os.path import join, isfile +from time import time +from typing import Optional +from ovos_utils.log import LOG +from ovos_utils.xdg_utils import xdg_cache_home + +_DEFAULT_BACKEND_URL = "https://hana.neonaialpha.com" +_client_config = {} +_client_config_path = join(xdg_cache_home(), "neon", "hana_token.json") +_headers = {} + + +class ServerException(Exception): + """Exception class representing a backend server communication error""" + + +def _init_client(backend_address: str): + """ + Initialize request headers for making backend requests. If a local cache is + available it will be used, otherwise an auth request will be made to the + specified backend server + @param backend_address: Hana server URL to connect to + """ + global _client_config + global _headers + + if not _client_config: + if isfile(_client_config_path): + with open(_client_config_path) as f: + _client_config = json.load(f) + else: + _get_token(backend_address) + + if not _headers: + _headers = {"Authorization": f"Bearer {_client_config['access_token']}"} + + +def _get_token(backend_address: str, username: str = "guest", + password: str = "password"): + """ + Get new auth tokens from the specified server. This will cache the returned + token, overwriting any previous data at the cache path. + @param backend_address: Hana server URL to connect to + @param username: Username to authorize + @param password: Password for specified username + """ + global _client_config + # TODO: username/password from configuration + resp = requests.post(f"{backend_address}/auth/login", + json={"username": username, + "password": password}) + if not resp.ok: + raise ServerException(f"Error logging into {backend_address}. " + f"{resp.status_code}: {resp.text}") + _client_config = resp.json() + with open(_client_config_path, "w+") as f: + json.dump(_client_config, f, indent=2) + + +def _refresh_token(backend_address: str): + """ + Get new tokens from the specified server using an existing refresh token + (if it exists). This will update the cached tokens and associated metadata. + @param backend_address: Hana server URL to connect to + """ + global _client_config + _init_client(backend_address) + update = requests.post(f"{backend_address}/auth/refresh", json={ + "access_token": _client_config.get("access_token"), + "refresh_token": _client_config.get("refresh_token"), + "client_id": _client_config.get("client_id")}) + if not update.ok: + raise ServerException(f"Error updating token from {backend_address}. " + f"{update.status_code}: {update.text}") + _client_config = update.json() + with open(_client_config_path, "w+") as f: + json.dump(_client_config, f, indent=2) + + +def request_backend(endpoint: str, request_data: dict, + server_url: str = _DEFAULT_BACKEND_URL) -> dict: + """ + Make a request to a Hana backend server and return the json response + @param endpoint: server endpoint to query + @param request_data: dict data to send in request body + @param server_url: Base URL of Hana server to query + @returns: dict response + """ + _init_client(server_url) + if time() >= _client_config.get("expiration", 0): + try: + _refresh_token(server_url) + except ServerException as e: + LOG.error(e) + _get_token(server_url) + resp = requests.post(f"{server_url}/{endpoint.lstrip('/')}", + json=request_data, headers=_headers) + if resp.ok: + return resp.json() + else: + raise ServerException(f"Error response {resp.status_code}: {resp.text}") diff --git a/tests/hana_util_tests.py b/tests/hana_util_tests.py new file mode 100644 index 00000000..cc207347 --- /dev/null +++ b/tests/hana_util_tests.py @@ -0,0 +1,128 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2022 Neongecko.com Inc. +# Contributors: Daniel McKnight, Guy Daniels, Elon Gasper, Richard Leeds, +# Regina Bloomstine, Casimiro Ferreira, Andrii Pernatii, Kirill Hrymailo +# BSD-3 License +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# 1. Redistributions of source code must retain the above copyright notice, +# this list of conditions and the following disclaimer. +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# 3. Neither the name of the copyright holder nor the names of its +# contributors may be used to endorse or promote products derived from this +# software without specific prior written permission. +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, +# THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +# PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR +# CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, +# EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, +# OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF +# LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING +# NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import json +import unittest +from os import remove +from os.path import join, dirname, isfile +from unittest.mock import patch + + +valid_config = {} +valid_headers = {} + + +class HanaUtilTests(unittest.TestCase): + import neon_utils.hana_utils + test_server = "https://hana.neonaialpha.com" + test_path = join(dirname(__file__), "hana_test.json") + neon_utils.hana_utils._client_config_path = test_path + + def tearDown(self) -> None: + global valid_config + global valid_headers + import neon_utils.hana_utils + if isfile(self.test_path): + remove(self.test_path) + if neon_utils.hana_utils._client_config: + valid_config = neon_utils.hana_utils._client_config + if neon_utils.hana_utils._headers: + valid_headers = neon_utils.hana_utils._headers + neon_utils.hana_utils._client_config = {} + neon_utils.hana_utils._headers = {} + + def test_request_backend(self): + # Use a valid config and skip extra auth + import neon_utils.hana_utils + neon_utils.hana_utils._client_config = valid_config + neon_utils.hana_utils._headers = valid_headers + from neon_utils.hana_utils import request_backend + resp = request_backend("/neon/get_response", + {"lang_code": "en-us", + "utterance": "who are you", + "user_profile": {}}, self.test_server) + self.assertEqual(resp['lang_code'], "en-us") + self.assertIsInstance(resp['answer'], str) + # TODO: Test invalid route, invalid request data + + def test_00_get_token(self): + from neon_utils.hana_utils import _get_token + + # Test valid request + _get_token(self.test_server) + from neon_utils.hana_utils import _client_config + self.assertTrue(isfile(self.test_path)) + with open(self.test_path) as f: + credentials_on_disk = json.load(f) + self.assertEqual(credentials_on_disk, _client_config) + # TODO: Test invalid request, rate-limited request + + @patch("neon_utils.hana_utils._get_token") + def test_refresh_token(self, get_token): + import neon_utils.hana_utils + + def _write_token(*_, **__): + with open(self.test_path, 'w+') as c: + json.dump(valid_config, c) + neon_utils.hana_utils._client_config = valid_config + + from neon_utils.hana_utils import _refresh_token + get_token.side_effect = _write_token + + self.assertFalse(isfile(self.test_path)) + + # Test valid request (auth + refresh) + _refresh_token(self.test_server) + get_token.assert_called_once() + from neon_utils.hana_utils import _client_config + self.assertTrue(isfile(self.test_path)) + with open(self.test_path) as f: + credentials_on_disk = json.load(f) + self.assertEqual(credentials_on_disk, _client_config) + + # Test refresh of existing token (no auth) + _refresh_token(self.test_server) + get_token.assert_called_once() + with open(self.test_path) as f: + new_credentials = json.load(f) + self.assertNotEqual(credentials_on_disk, new_credentials) + self.assertEqual(credentials_on_disk['client_id'], + new_credentials['client_id']) + self.assertEqual(credentials_on_disk['username'], + new_credentials['username']) + self.assertGreater(new_credentials['expiration'], + credentials_on_disk['expiration']) + self.assertNotEqual(credentials_on_disk['access_token'], + new_credentials['access_token']) + self.assertNotEqual(credentials_on_disk['refresh_token'], + new_credentials['refresh_token']) + + # TODO: Test invalid refresh + + +if __name__ == '__main__': + unittest.main() From 7392189ae79772da8c8b0a72ed732bc39b90663f Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 24 Jan 2024 02:23:53 +0000 Subject: [PATCH 08/19] Increment Version to 1.8.3a3 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 23abe1bf..4f208ac5 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "1.8.3a2" +__version__ = "1.8.3a3" From 8f1b8c37313e1dbf4ddb65fb3c0ba5c464a813d5 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 24 Jan 2024 02:24:33 +0000 Subject: [PATCH 09/19] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae8b04ee..aee519de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.8.3a3](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a3) (2024-01-24) + +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a2...1.8.3a3) + +**Merged pull requests:** + +- Add utilities for interfacing with Neon HANA [\#497](https://github.com/NeonGeckoCom/neon-utils/pull/497) ([NeonDaniel](https://github.com/NeonDaniel)) + ## [1.8.3a2](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a2) (2024-01-02) [Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a1...1.8.3a2) From 543df298daf44fc5feb3fc19f63546d0b0a4252a Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Wed, 7 Feb 2024 13:04:18 -0800 Subject: [PATCH 10/19] Ensure config directory exists before writing to it in `hana_utils` (#500) Co-authored-by: Daniel McKnight --- neon_utils/hana_utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/neon_utils/hana_utils.py b/neon_utils/hana_utils.py index 9fb788ee..2d37c17f 100644 --- a/neon_utils/hana_utils.py +++ b/neon_utils/hana_utils.py @@ -29,9 +29,9 @@ import requests import json -from os.path import join, isfile +from os import makedirs +from os.path import join, isfile, isdir, dirname from time import time -from typing import Optional from ovos_utils.log import LOG from ovos_utils.xdg_utils import xdg_cache_home @@ -84,6 +84,8 @@ def _get_token(backend_address: str, username: str = "guest", raise ServerException(f"Error logging into {backend_address}. " f"{resp.status_code}: {resp.text}") _client_config = resp.json() + if not isdir(dirname(_client_config_path)): + makedirs(dirname(_client_config_path)) with open(_client_config_path, "w+") as f: json.dump(_client_config, f, indent=2) From b626c6dcb3b90c5d0825dc66188a1e2c3e051571 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 7 Feb 2024 21:04:40 +0000 Subject: [PATCH 11/19] Increment Version to 1.8.3a4 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 4f208ac5..15ce4ca0 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "1.8.3a3" +__version__ = "1.8.3a4" From c36fd4a7e2ac0c77762d9ba18dbe96722ff394a6 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 7 Feb 2024 21:05:17 +0000 Subject: [PATCH 12/19] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aee519de..177a4c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.8.3a4](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a4) (2024-02-07) + +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a3...1.8.3a4) + +**Merged pull requests:** + +- Fix directory errors in hana\_utils [\#500](https://github.com/NeonGeckoCom/neon-utils/pull/500) ([NeonDaniel](https://github.com/NeonDaniel)) + ## [1.8.3a3](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a3) (2024-01-24) [Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a2...1.8.3a3) From 2bf52f74e6c8c63d36dee130ef05f4fcc7176908 Mon Sep 17 00:00:00 2001 From: David Blencowe Date: Wed, 14 Feb 2024 18:51:26 +0000 Subject: [PATCH 13/19] feat: adds function for installing packages via pip (#499) --- .gitignore | 3 ++- neon_utils/packaging_utils.py | 33 ++++++++++++++++++++++++++++++++- tests/packaging_util_tests.py | 23 +++++++++++++++++++++++ 3 files changed, 57 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 1a01f1f3..65eeee91 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ /dist/ /.pytest_cache/ tests/test-results.xml -*.egg-info/ \ No newline at end of file +*.egg-info/ +__pycache__ \ No newline at end of file diff --git a/neon_utils/packaging_utils.py b/neon_utils/packaging_utils.py index 174352e5..400818c6 100644 --- a/neon_utils/packaging_utils.py +++ b/neon_utils/packaging_utils.py @@ -29,7 +29,8 @@ import sys import re import importlib.util -from typing import Tuple, Optional +from typing import Tuple, Optional, List +from tempfile import mkstemp import pkg_resources import sysconfig @@ -265,3 +266,33 @@ def get_skill_license(): # TODO: Implement OSM version of this skill_data = dict_merge(default_skill, skill_data) skill_data["requirements"]["python"].sort() return dict(dict_merge(skill_data, readme_data)) + +def install_packages_from_pip(core_module: str, packages: List[str]) -> int: + """ + Install a Python package using pip + :param core_module: string neon core module to install dependency for + :param packages: List(string) list of packages to install + :returns: int pip exit code + """ + import pip + _, tmp_constraints_file = mkstemp() + _, tmp_requirements_file = mkstemp() + + install_str = " ".join(packages) + + with open(tmp_constraints_file, 'w', encoding="utf8") as f: + constraints = '\n'.join(get_package_dependencies(core_module)) + f.write(constraints) + LOG.info(f"Constraints={constraints}") + + with open(tmp_requirements_file, "w", encoding="utf8") as f: + for pkg in packages: + f.write(f"{pkg}\n") + + LOG.info(f"Requested installation of plugins: {install_str}") + pip_args = ['install', '-r', tmp_requirements_file, '-c', tmp_constraints_file] + result = pip.main(pip_args) if hasattr(pip, 'main') else pip._internal.main(pip_args) + + if result != 0: + return result + return 0 diff --git a/tests/packaging_util_tests.py b/tests/packaging_util_tests.py index a80351df..943c6c54 100644 --- a/tests/packaging_util_tests.py +++ b/tests/packaging_util_tests.py @@ -29,6 +29,7 @@ import os import sys import unittest +from unittest.mock import patch sys.path.append(os.path.dirname(os.path.dirname(os.path.realpath(__file__)))) from neon_utils.packaging_utils import * @@ -145,6 +146,28 @@ def test_build_skill_spec(self): self.assertEqual(valid_spec, skill_spec) # TODO: Actually validate exception cases? DM + + def test_install_packages_from_pip(self): + import pip + from neon_utils.packaging_utils import install_packages_from_pip + + with patch.object(pip, 'main', return_value=0) as mock_method: + test_result = install_packages_from_pip("neon-utils", ["pip-install-test"]) + + args, kwargs = mock_method.call_args + self.assertEqual(0, test_result) + mock_method.assert_called_once_with(['install', '-r', args[0][2], '-c', args[0][4]]) + + with open(args[0][2], "r", encoding="utf8") as f: + line = f.readline() + self.assertEqual("pip-install-test\n", line) + + mock_method.reset_mock() + test_result = install_packages_from_pip("neon-utils", ["pip-install-test", "pip-install-another"]) + + self.assertEqual(0, test_result) + mock_method.assert_called_once() + if __name__ == '__main__': From 9d1edc005a93bdeda441bef3bbc6cb6fb1bdc9ce Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 14 Feb 2024 18:51:43 +0000 Subject: [PATCH 14/19] Increment Version to 1.8.3a5 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 15ce4ca0..95245750 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "1.8.3a4" +__version__ = "1.8.3a5" From 2fd87cca0c57b5857c44035654980dd96b1cbffd Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Wed, 14 Feb 2024 18:52:24 +0000 Subject: [PATCH 15/19] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 177a4c20..e994fa99 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.8.3a5](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a5) (2024-02-14) + +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a4...1.8.3a5) + +**Merged pull requests:** + +- feat: adds function for installing packages via pip [\#499](https://github.com/NeonGeckoCom/neon-utils/pull/499) ([dblencowe](https://github.com/dblencowe)) + ## [1.8.3a4](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a4) (2024-02-07) [Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a3...1.8.3a4) From 6e5123d6292b8a171feddaf156f9ee33151c2690 Mon Sep 17 00:00:00 2001 From: Daniel McKnight <34697904+NeonDaniel@users.noreply.github.com> Date: Mon, 26 Feb 2024 12:24:48 -0800 Subject: [PATCH 16/19] Update Hana utils to use production server (#501) Update Hana utils and tests to support multiple server configs Co-authored-by: Daniel McKnight --- neon_utils/hana_utils.py | 23 +++++++++++++++-------- tests/hana_util_tests.py | 22 +++++++++++++++++----- 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/neon_utils/hana_utils.py b/neon_utils/hana_utils.py index 2d37c17f..a6e5850a 100644 --- a/neon_utils/hana_utils.py +++ b/neon_utils/hana_utils.py @@ -35,12 +35,16 @@ from ovos_utils.log import LOG from ovos_utils.xdg_utils import xdg_cache_home -_DEFAULT_BACKEND_URL = "https://hana.neonaialpha.com" +_DEFAULT_BACKEND_URL = "https://hana.neonaiservices.com" _client_config = {} -_client_config_path = join(xdg_cache_home(), "neon", "hana_token.json") _headers = {} +def _get_client_config_path(url: str = _DEFAULT_BACKEND_URL): + url_key = hash(url) + return join(xdg_cache_home(), "neon", f"hana_token_{url_key}.json") + + class ServerException(Exception): """Exception class representing a backend server communication error""" @@ -56,8 +60,9 @@ def _init_client(backend_address: str): global _headers if not _client_config: - if isfile(_client_config_path): - with open(_client_config_path) as f: + client_config_path = _get_client_config_path(backend_address) + if isfile(client_config_path): + with open(client_config_path) as f: _client_config = json.load(f) else: _get_token(backend_address) @@ -84,9 +89,10 @@ def _get_token(backend_address: str, username: str = "guest", raise ServerException(f"Error logging into {backend_address}. " f"{resp.status_code}: {resp.text}") _client_config = resp.json() - if not isdir(dirname(_client_config_path)): - makedirs(dirname(_client_config_path)) - with open(_client_config_path, "w+") as f: + client_config_path = _get_client_config_path(backend_address) + if not isdir(dirname(client_config_path)): + makedirs(dirname(client_config_path)) + with open(client_config_path, "w+") as f: json.dump(_client_config, f, indent=2) @@ -106,7 +112,8 @@ def _refresh_token(backend_address: str): raise ServerException(f"Error updating token from {backend_address}. " f"{update.status_code}: {update.text}") _client_config = update.json() - with open(_client_config_path, "w+") as f: + client_config_path = _get_client_config_path(backend_address) + with open(client_config_path, "w+") as f: json.dump(_client_config, f, indent=2) diff --git a/tests/hana_util_tests.py b/tests/hana_util_tests.py index cc207347..6eb81778 100644 --- a/tests/hana_util_tests.py +++ b/tests/hana_util_tests.py @@ -37,10 +37,8 @@ class HanaUtilTests(unittest.TestCase): - import neon_utils.hana_utils test_server = "https://hana.neonaialpha.com" test_path = join(dirname(__file__), "hana_test.json") - neon_utils.hana_utils._client_config_path = test_path def tearDown(self) -> None: global valid_config @@ -55,7 +53,10 @@ def tearDown(self) -> None: neon_utils.hana_utils._client_config = {} neon_utils.hana_utils._headers = {} - def test_request_backend(self): + @patch("neon_utils.hana_utils._get_client_config_path") + def test_request_backend(self, config_path): + config_path.return_value = self.test_path + # Use a valid config and skip extra auth import neon_utils.hana_utils neon_utils.hana_utils._client_config = valid_config @@ -69,7 +70,9 @@ def test_request_backend(self): self.assertIsInstance(resp['answer'], str) # TODO: Test invalid route, invalid request data - def test_00_get_token(self): + @patch("neon_utils.hana_utils._get_client_config_path") + def test_00_get_token(self, config_path): + config_path.return_value = self.test_path from neon_utils.hana_utils import _get_token # Test valid request @@ -81,8 +84,10 @@ def test_00_get_token(self): self.assertEqual(credentials_on_disk, _client_config) # TODO: Test invalid request, rate-limited request + @patch("neon_utils.hana_utils._get_client_config_path") @patch("neon_utils.hana_utils._get_token") - def test_refresh_token(self, get_token): + def test_refresh_token(self, get_token, config_path): + config_path.return_value = self.test_path import neon_utils.hana_utils def _write_token(*_, **__): @@ -121,6 +126,13 @@ def _write_token(*_, **__): self.assertNotEqual(credentials_on_disk['refresh_token'], new_credentials['refresh_token']) + def test_config_path(self): + from neon_utils.hana_utils import _get_client_config_path + path_1 = _get_client_config_path("https://hana.neonaialpha.com") + default = _get_client_config_path() + self.assertNotEqual(path_1, default) + self.assertEqual(dirname(path_1), dirname(default)) + # TODO: Test invalid refresh From 8938d845b34aea99c746e4e6469abd1024cfbcbd Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Mon, 26 Feb 2024 20:25:03 +0000 Subject: [PATCH 17/19] Increment Version to 1.8.3a6 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 95245750..7a482b20 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "1.8.3a5" +__version__ = "1.8.3a6" From e5b552b34cd131534fd832f71592e84202fed354 Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Mon, 26 Feb 2024 20:25:41 +0000 Subject: [PATCH 18/19] Update Changelog --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e994fa99..e96404ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [1.8.3a6](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a6) (2024-02-26) + +[Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a5...1.8.3a6) + +**Merged pull requests:** + +- Update Hana utils to prep for release [\#501](https://github.com/NeonGeckoCom/neon-utils/pull/501) ([NeonDaniel](https://github.com/NeonDaniel)) + ## [1.8.3a5](https://github.com/NeonGeckoCom/neon-utils/tree/1.8.3a5) (2024-02-14) [Full Changelog](https://github.com/NeonGeckoCom/neon-utils/compare/1.8.3a4...1.8.3a5) From 0466b7873b51e7ae085b6b129f78ad48b290f16f Mon Sep 17 00:00:00 2001 From: NeonDaniel Date: Tue, 27 Feb 2024 17:58:29 +0000 Subject: [PATCH 19/19] Increment Version to 1.9.0 --- version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/version.py b/version.py index 7a482b20..8c65da07 100644 --- a/version.py +++ b/version.py @@ -26,4 +26,4 @@ # NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS # SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -__version__ = "1.8.3a6" +__version__ = "1.9.0"