Repository: MycroftAI/lingua-franca

Issue:
Using default_time arg on extract_datetime skews results
**Describe the bug**
The Reminder Skill uses the `default_time` argument of `extract_datetime` and the extracted datetime is being returned 8 hours later than it should.

Originally reported
https://github.com/MycroftAI/skill-reminder/issues/50

**To Reproduce**
Have confirmed this by doing a quick modification of the LF unit tests - the first succeeds, second fails:
```python
    def test_extract_relativedatetime_en(self):
        def extractWithFormat(text, default_time):
            date = datetime(2017, 6, 27, 10, 1, 2, tzinfo=default_timezone())
            [extractedDate, leftover] = extract_datetime(text, date, default_time=default_time)
            extractedDate = extractedDate.strftime("%Y-%m-%d %H:%M:%S")
            return [extractedDate, leftover]

        def testExtract(text, expected_date, expected_leftover, default_time=None):
            res = extractWithFormat(normalize(text), default_time)
            self.assertEqual(res[0], expected_date, "for=" + text)
            self.assertEqual(res[1], expected_leftover, "for=" + text)

        testExtract("lets meet in a minute",
                    "2017-06-27 10:02:02", "lets meet")
        testExtract("lets meet in a minute",
                    "2017-06-27 10:02:02", "lets meet", default_time=now_local())
```
**Expected behavior**
If a datetime with a specific time is extracted then this argument should not affect the result. This is likely part of a bigger refactor of this function.

Discussion:

Patch:
diff --git a/lingua_franca/lang/parse_en.py b/lingua_franca/lang/parse_en.py
index 3075715..e5e9ed2 100644
--- a/lingua_franca/lang/parse_en.py
+++ b/lingua_franca/lang/parse_en.py
@@ -13,7 +13,7 @@
 # See the License for the specific language governing permissions and
 # limitations under the License.
 #
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, time
 
 from dateutil.relativedelta import relativedelta
 
@@ -678,7 +678,7 @@ def extract_datetime_en(text, anchorDate=None, default_time=None):
 
     if text == "":
         return None
-
+    default_time = default_time or time(0, 0, 0)
     found = False
     daySpecified = False
     dayOffset = False
@@ -1390,7 +1390,9 @@ def extract_datetime_en(text, anchorDate=None, default_time=None):
     else:
         # ignore the current HH:MM:SS if relative using days or greater
         if hrOffset == 0 and minOffset == 0 and secOffset == 0:
-            extractedDate = extractedDate.replace(hour=0, minute=0, second=0)
+            extractedDate = extractedDate.replace(hour=default_time.hour,
+                                                  minute=default_time.minute,
+                                                  second=default_time.second)
 
     if yearOffset != 0:
         extractedDate = extractedDate + relativedelta(years=yearOffset)
@@ -1398,7 +1400,15 @@ def extract_datetime_en(text, anchorDate=None, default_time=None):
         extractedDate = extractedDate + relativedelta(months=monthOffset)
     if dayOffset != 0:
         extractedDate = extractedDate + relativedelta(days=dayOffset)
-    if hrAbs != -1 and minAbs != -1:
+    if hrOffset != 0:
+        extractedDate = extractedDate + relativedelta(hours=hrOffset)
+    if minOffset != 0:
+        extractedDate = extractedDate + relativedelta(minutes=minOffset)
+    if secOffset != 0:
+        extractedDate = extractedDate + relativedelta(seconds=secOffset)
+
+
+    if hrAbs != -1 and minAbs != -1 and not hrOffset and not minOffset and not secOffset:
         # If no time was supplied in the string set the time to default
         # time if it's available
         if hrAbs is None and minAbs is None and default_time is not None:
@@ -1407,17 +1417,13 @@ def extract_datetime_en(text, anchorDate=None, default_time=None):
             hrAbs = hrAbs or 0
             minAbs = minAbs or 0
 
-        extractedDate = extractedDate + relativedelta(hours=hrAbs,
-                                                      minutes=minAbs)
+        extractedDate = extractedDate.replace(hour=hrAbs,
+                                              minute=minAbs)
+
         if (hrAbs != 0 or minAbs != 0) and datestr == "":
             if not daySpecified and anchorDate > extractedDate:
                 extractedDate = extractedDate + relativedelta(days=1)
-    if hrOffset != 0:
-        extractedDate = extractedDate + relativedelta(hours=hrOffset)
-    if minOffset != 0:
-        extractedDate = extractedDate + relativedelta(minutes=minOffset)
-    if secOffset != 0:
-        extractedDate = extractedDate + relativedelta(seconds=secOffset)
+
     for idx, word in enumerate(words):
         if words[idx] == "and" and \
                 words[idx - 1] == "" and words[idx + 1] == "":


Test patch:
diff --git a/test/test_format.py b/test/test_format.py
index 518d501..4901e06 100644
--- a/test/test_format.py
+++ b/test/test_format.py
@@ -386,6 +386,9 @@ class TestPronounceNumber(unittest.TestCase):
                                           short_scale=False), "eighteen "
                                                               "trillionth")
 
+# def nice_time(dt, lang="en-us", speech=True, use_24hour=False,
+#              use_ampm=False):
+
 
 class TestNiceDateFormat(unittest.TestCase):
     @classmethod
@@ -601,7 +604,8 @@ class TestNiceDateFormat(unittest.TestCase):
                 dt = datetime.datetime(i, 1, 31, 13, 2, 3, tzinfo=default_timezone())
                 self.assertTrue(len(nice_year(dt, lang=lang)) > 0)
                 # Looking through the date sequence can be helpful
-                # print(nice_year(dt, lang=lang))
+
+#                print(nice_year(dt, lang=lang))
 
     def test_nice_duration(self):
         self.assertEqual(nice_duration(1), "one second")
diff --git a/test/test_format_cs.py b/test/test_format_cs.py
index fc93e93..cdf4c5a 100644
--- a/test/test_format_cs.py
+++ b/test/test_format_cs.py
@@ -579,8 +579,8 @@ class TestNiceDateFormat(unittest.TestCase):
                                    tzinfo=default_timezone())
             self.assertTrue(len(nice_year(dt, lang=lang)) > 0)
             # Looking through the date sequence can be helpful
-                # print(nice_year(dt, lang=lang))
 
+#                print(nice_year(dt, lang=lang))
     def test_nice_duration(self):
 
         self.assertEqual(nice_duration(1), "jedna sekunda")
diff --git a/test/test_format_da.py b/test/test_format_da.py
index 1b1f938..981ee90 100644
--- a/test/test_format_da.py
+++ b/test/test_format_da.py
@@ -202,6 +202,8 @@ class TestPronounceNumber(unittest.TestCase):
                          "minus enogtyve komma to tre fire nul nul")
 
 
+# def nice_time(dt, lang="da-dk", speech=True, use_24hour=False,
+#              use_ampm=False):
 class TestNiceDateFormat_da(unittest.TestCase):
     def test_convert_times_da(self):
         dt = datetime.datetime(2017, 1, 31, 13, 22, 3, tzinfo=default_timezone())
diff --git a/test/test_format_de.py b/test/test_format_de.py
index 12f8fe9..b04ad51 100644
--- a/test/test_format_de.py
+++ b/test/test_format_de.py
@@ -208,6 +208,8 @@ class TestPronounceNumber(unittest.TestCase):
                          "minus einundzwanzig Komma zwei drei vier null null")
 
 
+# def nice_time(dt, lang="de-de", speech=True, use_24hour=False,
+#              use_ampm=False):
 class TestNiceDateFormat_de(unittest.TestCase):
     def setUp(self):
         self.old_lang = get_default_lang()
diff --git a/test/test_format_fa.py b/test/test_format_fa.py
index effcd43..8bc0111 100644
--- a/test/test_format_fa.py
+++ b/test/test_format_fa.py
@@ -214,6 +214,9 @@ class TestPronounceNumber(unittest.TestCase):
         
 
 
+# def nice_time(dt, lang="en-us", speech=True, use_24hour=False,
+#              use_ampm=False):
+
 class TestNiceDateFormat(unittest.TestCase):
     @classmethod
     def setUpClass(cls):
diff --git a/test/test_format_fr.py b/test/test_format_fr.py
index 1650200..a7b5e71 100644
--- a/test/test_format_fr.py
+++ b/test/test_format_fr.py
@@ -180,6 +180,8 @@ class TestPronounceNumber_fr(unittest.TestCase):
                          "moins vingt-et-un virgule deux trois quatre")
 
 
+# def nice_time(dt, lang="en-us", speech=True, use_24hour=False,
+#              use_ampm=False):
 class TestNiceDateFormat_fr(unittest.TestCase):
     def test_convert_times_fr(self):
         dt = datetime.datetime(2017, 1, 31,
diff --git a/test/test_format_hu.py b/test/test_format_hu.py
index 9d21cdc..6d4b1e7 100644
--- a/test/test_format_hu.py
+++ b/test/test_format_hu.py
@@ -204,6 +204,8 @@ class TestPronounceNumber(unittest.TestCase):
                          "huszonháromezer-négyszáz százezred")
 
 
+# def nice_time(dt, lang="hu-hu", speech=True, use_24hour=False,
+#              use_ampm=False):
 class TestNiceDateFormat_hu(unittest.TestCase):
     def test_convert_times_hu(self):
         dt = datetime.datetime(2017, 1, 31,
diff --git a/test/test_format_nl.py b/test/test_format_nl.py
index 82096a5..3e16a4d 100644
--- a/test/test_format_nl.py
+++ b/test/test_format_nl.py
@@ -195,6 +195,8 @@ class TestPronounceNumber(unittest.TestCase):
                          "min éénentwintig komma twee drie vier nul nul")
 
 
+# def nice_time(dt, lang="nl-nl", speech=True, use_24hour=False,
+#              use_ampm=False):
 class TestNiceDateFormat_nl(unittest.TestCase):
     def test_convert_times_nl(self):
         dt = datetime.datetime(2017, 1, 31,
diff --git a/test/test_format_sl.py b/test/test_format_sl.py
index f5f5ec9..423127f 100644
--- a/test/test_format_sl.py
+++ b/test/test_format_sl.py
@@ -525,7 +525,8 @@ class TestNiceDateFormat(unittest.TestCase):
                                        tzinfo=default_timezone())
                 self.assertTrue(len(nice_year(dt, lang=lang)) > 0)
                 # Looking through the date sequence can be helpful
-                # print(nice_year(dt, lang=lang))
+
+#                print(nice_year(dt, lang=lang))
 
     def test_nice_duration(self):
         # TODO implement better plural support for nice_duration
diff --git a/test/test_format_sv.py b/test/test_format_sv.py
index 9db2ec1..625b9bd 100644
--- a/test/test_format_sv.py
+++ b/test/test_format_sv.py
@@ -202,6 +202,8 @@ class TestPronounceNumber(unittest.TestCase):
                          "minus tjugoen komma två tre fyra noll noll")
 
 
+# def nice_time(dt, lang="sv-se", speech=True, use_24hour=False,
+#              use_ampm=False):
 class TestNiceDateFormat_sv(unittest.TestCase):
     def test_convert_times_sv(self):
         dt = datetime.datetime(2017, 1, 31, 13, 22, 3, tzinfo=default_timezone())
diff --git a/test/test_parse.py b/test/test_parse.py
index 7ee6b78..542b572 100644
--- a/test/test_parse.py
+++ b/test/test_parse.py
@@ -14,7 +14,7 @@
 # limitations under the License.
 #
 import unittest
-from datetime import datetime, timedelta
+from datetime import datetime, timedelta, time
 from dateutil import tz
 
 from lingua_franca import load_language, unload_language, set_default_lang
@@ -161,7 +161,7 @@ class TestNormalize(unittest.TestCase):
         self.assertEqual(extract_number("2 fifth",
                                         ordinals=True), 5)
         self.assertEqual(extract_number("2 fifths",
-                                        ordinals=False), 2/5)
+                                        ordinals=False), 2 / 5)
         self.assertEqual(extract_number("2 fifths",
                                         ordinals=None), 2)
 
@@ -213,7 +213,6 @@ class TestNormalize(unittest.TestCase):
                                         ordinals=None), 8)
 
     def test_extract_number(self):
-
         self.assertEqual(extract_number("this is 2 test"), 2)
         self.assertEqual(extract_number("this is test number 4"), 4)
         self.assertEqual(extract_number("three cups"), 3)
@@ -331,7 +330,7 @@ class TestNormalize(unittest.TestCase):
         self.assertEqual(extract_duration("The movie is one hour, fifty seven"
                                           " and a half minutes long"),
                          (timedelta(hours=1, minutes=57.5),
-                             "The movie is ,  long"))
+                          "The movie is ,  long"))
         self.assertEqual(extract_duration("Four and a Half minutes until"
                                           " sunset"),
                          (timedelta(minutes=4.5), "until sunset"))
@@ -713,7 +712,67 @@ class TestNormalize(unittest.TestCase):
     def test_extract_date_years(self):
         date = datetime(2017, 6, 27, tzinfo=default_timezone())  # Tue June 27, 2017
         self.assertEqual(extract_datetime('in 2007', date)[0],
-            datetime(2007, 6, 27, tzinfo=date.tzinfo))
+                         datetime(2007, 6, 27, tzinfo=date.tzinfo))
+
+
+    def test_extractdatetime_with_default_time_en(self):
+        def extractWithFormat(text):
+            default_time = time(15, 4, tzinfo=default_timezone())
+            date = datetime(2017, 6, 27, 13, 4, tzinfo=default_timezone())  # Tue June 27, 2017 @ 1:04pm
+            [extractedDate, leftover] = extract_datetime(text, date, default_time=default_time)
+            extractedDate = extractedDate.strftime("%Y-%m-%d %H:%M:%S")
+            return [extractedDate, leftover]
+
+        def testExtract(text, expected_date, expected_leftover):
+            res = extractWithFormat(normalize(text))
+            self.assertEqual(res[0], expected_date, "for=" + text)
+            self.assertEqual(res[1], expected_leftover, "for=" + text)
+
+        # ignore default time arg
+        testExtract("in a second",
+                    "2017-06-27 13:04:01", "")
+        testExtract("in a minute",
+                    "2017-06-27 13:05:00", "")
+        testExtract("in an hour",
+                    "2017-06-27 14:04:00", "")
+
+        # use default time
+        testExtract("in a couple weeks",
+                    "2017-07-11 15:04:00", "")
+        testExtract("in a couple of weeks",
+                    "2017-07-11 15:04:00", "")
+        testExtract("in a couple months",
+                    "2017-08-27 15:04:00", "")
+        testExtract("in a couple years",
+                    "2019-06-27 15:04:00", "")
+        testExtract("in a couple of months",
+                    "2017-08-27 15:04:00", "")
+        testExtract("in a couple of years",
+                    "2019-06-27 15:04:00", "")
+        testExtract("in a decade",
+                    "2027-06-27 15:04:00", "")
+        testExtract("in a couple of decades",
+                    "2037-06-27 15:04:00", "")
+        testExtract("next decade",
+                    "2027-06-27 15:04:00", "")
+        testExtract("in a century",
+                    "2117-06-27 15:04:00", "")
+        testExtract("in a millennium",
+                    "3017-06-27 15:04:00", "")
+        testExtract("in a couple decades",
+                    "2037-06-27 15:04:00", "")
+        testExtract("in 5 decades",
+                    "2067-06-27 15:04:00", "")
+        testExtract("in a couple centuries",
+                    "2217-06-27 15:04:00", "")
+        testExtract("in a couple of centuries",
+                    "2217-06-27 15:04:00", "")
+        testExtract("in 2 centuries",
+                    "2217-06-27 15:04:00", "")
+        testExtract("in a couple millenniums",
+                    "4017-06-27 15:04:00", "")
+        testExtract("in a couple of millenniums",
+                    "4017-06-27 15:04:00", "")
 
     def test_extract_ambiguous_time_en(self):
         morning = datetime(2017, 6, 27, 8, 1, 2, tzinfo=default_timezone())
@@ -768,7 +827,7 @@ class TestNormalize(unittest.TestCase):
                          (not_local_dt.year, not_local_dt.month, not_local_dt.day,
                           not_local_dt.hour, not_local_dt.minute, not_local_dt.second,
                           not_local_dt.tzinfo))
-        self.assertNotEqual((test_dt.year, test_dt.month, test_dt.day, 
+        self.assertNotEqual((test_dt.year, test_dt.month, test_dt.day,
                              test_dt.hour, test_dt.minute, test_dt.second,
                              test_dt.tzinfo),
                             (local_dt.year, local_dt.month, local_dt.day,