diff --git a/requirements/requirements.txt b/requirements/requirements.txt index 0982a276..ab406d96 100644 --- a/requirements/requirements.txt +++ b/requirements/requirements.txt @@ -6,3 +6,4 @@ ovos-utils~=0.0, >=0.0.34 neon-utils~=1.12 ovos-workshop~=0.0,>=0.0.12 ovos-lingua-franca~=0.4 +pydantic~=2.0 diff --git a/skill.json b/skill.json index 366add1a..e7038515 100644 --- a/skill.json +++ b/skill.json @@ -21,6 +21,7 @@ "ovos-lingua-franca~=0.4", "ovos-utils~=0.0, >=0.0.34", "ovos-workshop~=0.0,>=0.0.12", + "pydantic~=2.0", "pytz>=2022.1", "timezonefinder~=5.2" ], diff --git a/skill_date_time/__init__.py b/skill_date_time/__init__.py index 135e9447..06785fb6 100644 --- a/skill_date_time/__init__.py +++ b/skill_date_time/__init__.py @@ -44,6 +44,7 @@ import geocoder import pytz +from time import time from datetime import tzinfo, datetime from typing import Union, Optional @@ -62,6 +63,10 @@ from ovos_workshop.decorators import intent_handler, skill_api_method, \ resting_screen_handler +from skill_date_time.api_data_models import DisplayCurrentTimeResponse, \ + DisplayDateReponse, MonthDateResponse, TimeInLocationRequest, \ + WeekdayResponse, YearResponse, CurrentTimeResponse, FormattedTimeResponse + day_to_dialog = { 0: "word_monday", @@ -128,20 +133,15 @@ def handle_idle(self, _): self.gui.show_page('idle') @skill_api_method - def get_display_date(self, day: Optional[datetime] = None, - location: Optional[str] = None, - message: Message = None) -> str: + def get_display_date(self, + request: TimeInLocationRequest = TimeInLocationRequest()) -> DisplayDateReponse: """ - Get the full date for day or location in the configured format. - :param day: datetime object to display - :param location: location to get the current datetime of - :param message: Message containing user profile for request - :returns: The full date in the user configured format + Get the full date in the configured format. """ - message = message or dig_for_message() + # TODO: Refactor to accept format as a param? + message = dig_for_message() unit_prefs = get_user_prefs(message)['units'] - if not day: - day = self.get_local_datetime(location, None) + day = self.get_local_datetime(request.location, None) if unit_prefs.get('date') == 'MDY': return day.strftime("%-m/%-d/%Y") elif unit_prefs.get('date') == 'YMD': @@ -152,29 +152,15 @@ def get_display_date(self, day: Optional[datetime] = None, return day.strftime("%Y/%-d/%-m") @skill_api_method - def get_display_current_time(self, location: Optional[str] = None, - dt_utc: Optional[datetime] = None, - message: Message = None) -> \ - Optional[str]: - """ - Get a formatted digital clock time based on the user preferences - :param location: location to get the current datetime of - :param dt_utc: UTC datetime to override current datetime - :param: Time in the user configured format if location is valid - else None - :param message: Message containing user profile for request - :returns: Formatted string time or None if Exception - """ - message = message or dig_for_message() + def get_display_current_time(self, request: TimeInLocationRequest = TimeInLocationRequest(), + ) -> DisplayCurrentTimeResponse: + """ + Get a formatted digital clock time based on the user's preferences + """ + message = dig_for_message() + location = request.location try: dt = self.get_local_datetime(location, message) - if dt_utc: - if location: - dt = dt_utc.astimezone(dt.tzinfo) - else: - dt = dt_utc - if not dt: - return None load_language(self.lang) # Logging here produces logs every 10s # LOG.debug(f"Got time: {dt.isoformat()}|use_24h={self.use_24hour}") @@ -189,16 +175,12 @@ def get_display_current_time(self, location: Optional[str] = None, return None @skill_api_method - def get_weekday(self, day: Optional[datetime] = None, - location: Optional[str] = None) -> str: + def get_weekday(self, request: TimeInLocationRequest = TimeInLocationRequest(), + ) -> WeekdayResponse: """ - Get the weekday name for a given day. - :param day: datetime object to get weekday of - :param location: optional location to get weekday for - :returns: The name of the weekday (i.e. Monday) + Get the weekday name for a given day. (Monday, Tuesday, etc.) """ - if not day: - day = self.get_local_datetime(location, None) + day = self.get_local_datetime(request.location, None) if self.lang in date_time_format.lang_config.keys(): localized_day_names = list( date_time_format.lang_config[self.lang]['weekday'].values()) @@ -208,21 +190,15 @@ def get_weekday(self, day: Optional[datetime] = None, return weekday.capitalize() @skill_api_method - def get_month_date(self, day: Optional[datetime] = None, - location: Optional[str] = None, - message: Message = None) -> str: - """ - Get the month and date for a given day and location - :param day: optional datetime object to get month and date for - :param location: optional location to get the current datetime of - :param message: Message containing user profile for request - :returns: date in the format DD MONTH or MONTH DD - depending on the users date_format setting. - """ - message = message or dig_for_message() + def get_month_date(self, + request: TimeInLocationRequest = TimeInLocationRequest()) -> MonthDateResponse: + """ + Get the month and date for a given day and location in the format + DD MONTH or MONTH DD, depending on the user's date_format setting. + """ + message = dig_for_message() unit_prefs = get_user_prefs(message)["units"] - if not day: - day = self.get_local_datetime(location, None) + day = self.get_local_datetime(request.location, None) if self.lang in date_time_format.lang_config.keys(): localized_month_names = \ date_time_format.lang_config[self.lang]['month'] @@ -236,19 +212,37 @@ def get_month_date(self, day: Optional[datetime] = None, return f"{day.strftime('%d')} {month}" @skill_api_method - def get_year(self, day: Optional[datetime] = None, - location: Optional[str] = None) -> str: + def get_year(self, + request: TimeInLocationRequest = TimeInLocationRequest()) -> YearResponse: """ - Get the year for a given day and location - :param day: optional datetime object to get year for - :param location: optional location to get the current year of - :returns: year in the format YYYY + Get the year for a given day and location in YYYY format """ - if not day: - day = self.get_local_datetime(location) + day = self.get_local_datetime(request.location) return day.strftime("%Y") @skill_api_method + def get_current_time(self) -> CurrentTimeResponse: + """ + Get the current epoch timestamp in seconds + """ + return time() + + @skill_api_method + def get_formatted_time(self, request: TimeInLocationRequest = TimeInLocationRequest()) -> FormattedTimeResponse: + """ + Get the current time formatted as time, date, and weekday strings + """ + location = request.location or self.location['city']['name'] + dt = self.get_local_datetime(location) + if not dt: + raise ValueError(f"Invalid location: {location}") + formatted_time = dt.strftime("%H:%M") + formatted_date = dt.strftime("%Y-%m-%d") + current_weekday = dt.strftime("%A") + return FormattedTimeResponse(formatted_time=formatted_time, + formatted_date=formatted_date, + current_weekday=current_weekday) + def get_next_leap_year(self, year: int) -> int: """ Get the next calendar year that will be a leap year. @@ -263,7 +257,6 @@ def get_next_leap_year(self, year: int) -> int: else: return self.get_next_leap_year(next_year) - @skill_api_method def is_leap_year(self, year: int) -> bool: """ Check if given year is a leap year. @@ -288,9 +281,10 @@ def handle_query_time(self, message: Message): # An error should have been spoken by now, location wasn't valid return + request_obj = TimeInLocationRequest(location=location) self.show_time_gui(location, - self.get_display_current_time(location), - self.get_display_date(location=location)) + self.get_display_current_time(request_obj), + self.get_display_date(request_obj)) if location: self.speak_dialog("date_time_in_location", {"location": location.title(), diff --git a/skill_date_time/api_data_models.py b/skill_date_time/api_data_models.py new file mode 100644 index 00000000..5b4dee2e --- /dev/null +++ b/skill_date_time/api_data_models.py @@ -0,0 +1,65 @@ +# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework +# All trademark and other rights reserved by their respective owners +# Copyright 2008-2025 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. + +from typing import Optional +from pydantic import BaseModel, RootModel, Field + + +class TimeInLocationRequest(BaseModel): + location: Optional[str] = Field( + default=None, description="Location to get time information for") + + +class DisplayDateReponse(RootModel): + root: str = Field(description="Date in the user-configured format") + + +class DisplayCurrentTimeResponse(RootModel): + root: str = Field(description="Current time in the user-configured format") + + +class WeekdayResponse(RootModel): + root: str = Field(description="Weekday printed in the user's language") + + +class MonthDateResponse(RootModel): + root: str = Field(description="Month and day in the user-configured format") + + +class YearResponse(RootModel): + root: str = Field(description="Year (YYYY)") + + +class CurrentTimeResponse(RootModel): + root: float = Field(description="Current epoch time in seconds") + + +class FormattedTimeResponse(BaseModel): + formatted_time: str = Field(description="Current time in HH:MM format") + formatted_date: str = Field(description="Current date in YYYY-MM-DD format") + current_weekday: str = Field(description="Current weekday name in English") diff --git a/test/test_skill.py b/test/test_skill.py index 6a70d422..8883d8af 100644 --- a/test/test_skill.py +++ b/test/test_skill.py @@ -30,11 +30,17 @@ import pytest import datetime as dt +from os import environ from pytz import timezone -from mock import Mock + +from unittest.mock import Mock, patch from ovos_bus_client import Message from neon_minerva.tests.skill_unit_test_base import SkillTestCase +from skill_date_time import TimeSkill +from skill_date_time.api_data_models import TimeInLocationRequest + +environ['TEST_SKILL_ENTRYPOINT'] = 'skill-date_time.neongeckocom' class TestSkillMethods(SkillTestCase): @@ -43,6 +49,7 @@ def test_00_skill_init(self): from neon_utils.skills import NeonSkill self.assertIsInstance(self.skill, NeonSkill) + self.assertIsInstance(self.skill, TimeSkill) def test_handle_idle(self): class MockGui: @@ -76,35 +83,40 @@ def clear(): self.skill.gui = real_gui - def test_get_display_date(self): + @patch('skill_date_time.dig_for_message') + @patch.object(TimeSkill, 'get_local_datetime') + def test_get_display_date(self, get_time, dig_for_message): from neon_utils.user_utils import get_default_user_config config = get_default_user_config() config['user']['username'] = 'test_user' config['units']['date'] = "MDY" test_message = Message("test", {}, {"username": "test_user", "user_profiles": [config]}) + dig_for_message.return_value = test_message test_date = dt.datetime(month=1, day=2, year=2000) + get_time.return_value = test_date - date_str = self.skill.get_display_date(test_date, message=test_message) + date_str = self.skill.get_display_date() self.assertEqual(date_str, "1/2/2000") config['units']['date'] = "DMY" test_message = Message("test", {}, {"username": "test_user", "user_profiles": [config]}) - date_str = self.skill.get_display_date(test_date, message=test_message) + dig_for_message.return_value = test_message + + date_str = self.skill.get_display_date() self.assertEqual(date_str, "2/1/2000") config['units']['date'] = "YMD" test_message = Message("test", {}, {"username": "test_user", "user_profiles": [config]}) - date_str = self.skill.get_display_date(test_date, message=test_message) + dig_for_message.return_value = test_message + date_str = self.skill.get_display_date() self.assertEqual(date_str, "2000/1/2") - now_date_str = self.skill.get_display_date() - self.assertNotEqual(date_str, now_date_str) - - def test_get_display_current_time(self): + @patch('skill_date_time.dig_for_message') + def test_get_display_current_time(self, dig_for_message): from neon_utils.user_utils import get_default_user_config config = get_default_user_config() config['user']['username'] = 'test_user' @@ -115,7 +127,7 @@ def test_get_display_current_time(self): self.assertEqual(len(current_time.split(':')), 2) # Specify location - current_time_honolulu = self.skill.get_display_current_time("honolulu") + current_time_honolulu = self.skill.get_display_current_time(TimeInLocationRequest(location="honolulu")) self.assertIsInstance(current_time_honolulu, str) self.assertEqual(len(current_time_honolulu.split(':')), 2) self.assertIn('m', current_time_honolulu.lower()) @@ -124,17 +136,17 @@ def test_get_display_current_time(self): config['units']['time'] = 24 test_message = Message("test", {}, {"username": "test_user", "user_profiles": [config]}) + dig_for_message.return_value = test_message # Default location, specify time 24h - dt_utc = dt.datetime.now(dt.timezone.utc).replace(hour=23, minute=30) - utc_time = self.skill.get_display_current_time(dt_utc=dt_utc, - message=test_message) - self.assertEqual(utc_time, "23:30") + #dt_utc = dt.datetime.now(dt.timezone.utc).replace(hour=23, minute=30) + utc_time = self.skill.get_display_current_time() + self.assertEqual(len(utc_time.split()), 1) # "23:30" - # Specify location, 24h - az_time = self.skill.get_display_current_time("phoenix", dt_utc, - message=test_message) - self.assertEqual(az_time, "16:30") + ## Specify location, 24h + #az_time = self.skill.get_display_current_time("phoenix", dt_utc, + # message=test_message) + #self.assertEqual(az_time, "16:30") self.skill.settings['use_ampm'] = True config['units']['time'] = 12 @@ -142,70 +154,94 @@ def test_get_display_current_time(self): # Default location with AM/PM test_message = Message("test", {}, {"username": "test_user", "user_profiles": [config]}) - utc_time = self.skill.get_display_current_time(dt_utc=dt_utc, - message=test_message) - self.assertEqual(utc_time, "11:30 PM") + dig_for_message.return_value = test_message - # Specify location with AM/PM - az_time = self.skill.get_display_current_time("phoenix", dt_utc, - message=test_message) - self.assertEqual(az_time, "4:30 PM") + utc_time = self.skill.get_display_current_time() + self.assertEqual(len(utc_time.split()), 2) # "11:30 PM" + + # # Specify location with AM/PM + # az_time = self.skill.get_display_current_time("phoenix", dt_utc, + # message=test_message) + # self.assertEqual(az_time, "4:30 PM") self.skill.settings['use_ampm'] = False # Default location, no AM/PM - utc_time = self.skill.get_display_current_time(dt_utc=dt_utc, - message=test_message) - self.assertEqual(utc_time, "11:30") + utc_time = self.skill.get_display_current_time() + self.assertEqual(len(utc_time.split()), 1) # "23:30" # Specify location, always shows AM/PM - az_time = self.skill.get_display_current_time("phoenix", dt_utc, - message=test_message) - self.assertEqual(az_time, "4:30 PM") + az_time = self.skill.get_display_current_time(TimeInLocationRequest(location="phoenix")) + self.assertEqual(len(az_time.split()), 2) # "4:30 PM" - def test_get_weekday(self): + @patch.object(TimeSkill, 'get_local_datetime') + def test_get_weekday(self, get_local_datetime): + # Simple case + get_local_datetime.return_value = dt.datetime.now(dt.timezone.utc) self.assertIsInstance(self.skill.get_weekday(), str) + + # Relative day change today = dt.datetime.now(dt.timezone.utc) + get_local_datetime.return_value = today + today_day = self.skill.get_weekday() tomorrow = dt.datetime.now(dt.timezone.utc) + dt.timedelta(days=1) - self.assertNotEqual(self.skill.get_weekday(today), - self.skill.get_weekday(tomorrow)) - self.assertEqual(self.skill.get_weekday(location="Seattle"), - self.skill.get_weekday(location="Portland")) + get_local_datetime.return_value = tomorrow + tomorrow_day = self.skill.get_weekday() + self.assertNotEqual(today_day, tomorrow_day) + + # With Location + self.assertEqual(self.skill.get_weekday(TimeInLocationRequest(location="Seattle")), + self.skill.get_weekday(TimeInLocationRequest(location="Portland"))) + # Specific known case known_day = dt.datetime(day=1, month=1, year=2000) - self.assertEqual(self.skill.get_weekday(known_day), "Saturday") + get_local_datetime.return_value = known_day + self.assertEqual(self.skill.get_weekday(), "Saturday") - def test_get_month_date(self): + @patch('skill_date_time.dig_for_message') + @patch.object(TimeSkill, 'get_local_datetime') + def test_get_month_date(self, get_local_datetime, dig_for_message): from neon_utils.user_utils import get_default_user_config config = get_default_user_config() config['user']['username'] = 'test_user' test_date = dt.datetime(month=1, day=1, year=2000) + get_local_datetime.return_value = test_date + # TODO: Refactor is removing `message` param config['units']['date'] = "MDY" test_message = Message("test", {}, {"username": "test_user", "user_profiles": [config]}) - date_str = self.skill.get_month_date(test_date, message=test_message) + dig_for_message.return_value = test_message + date_str = self.skill.get_month_date() self.assertEqual(date_str, "January 01") config['units']['date'] = "DMY" test_message = Message("test", {}, {"username": "test_user", "user_profiles": [config]}) - date_str = self.skill.get_month_date(test_date, message=test_message) + dig_for_message.return_value = test_message + date_str = self.skill.get_month_date() self.assertEqual(date_str, "01 January") config['units']['date'] = "YMD" test_message = Message("test", {}, {"username": "test_user", "user_profiles": [config]}) - date_str = self.skill.get_month_date(test_date, message=test_message) + dig_for_message.return_value = test_message + date_str = self.skill.get_month_date() self.assertEqual(date_str, "January 01") + + # TODO: Validate with non-mocked get_local_datetime and location input - now_date_str = self.skill.get_month_date() - self.assertNotEqual(date_str, now_date_str) - - def test_get_year(self): + @patch.object(TimeSkill, 'get_local_datetime') + def test_get_year(self, get_local_datetime): + # Test simple case + get_local_datetime.return_value = dt.datetime.now(dt.timezone.utc) self.assertIsInstance(self.skill.get_year(), str) + + # Test known case date = datetime.datetime(month=1, day=1, year=2000) - self.assertEqual(self.skill.get_year(date), "2000") - self.assertEqual(self.skill.get_year(date, "Seattle"), "2000") - self.assertIsInstance(self.skill.get_year(location="Seattle"), str) + get_local_datetime.return_value = date + self.assertEqual(self.skill.get_year(), "2000") + # Test with location input + self.assertEqual(self.skill.get_year(TimeInLocationRequest(location="Seattle")), + "2000") def test_get_next_leap_year(self): for year in (2000, 2001, 2002, 2003):