diff --git a/adafruit_datetime.py b/adafruit_datetime.py index c9d34fd..735ac3b 100755 --- a/adafruit_datetime.py +++ b/adafruit_datetime.py @@ -29,6 +29,7 @@ # pylint: disable=too-many-lines import time as _time import math as _math +import re as _re from micropython import const __version__ = "0.0.0-auto.0" @@ -62,6 +63,8 @@ ) _DAYNAMES = (None, "Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun") +_INVALID_ISO_ERROR = "Invalid isoformat string: '{}'" + # Utility functions - universal def _cmp(obj_x, obj_y): return 0 if obj_x == obj_y else 1 if obj_x > obj_y else -1 @@ -657,6 +660,20 @@ def fromordinal(cls, ordinal): y, m, d = _ord2ymd(ordinal) return cls(y, m, d) + @classmethod + def fromisoformat(cls, date_string): + """Return a date object constructed from an ISO date format. + Valid format is ``YYYY-MM-DD`` + + """ + match = _re.match( + r"([0-9][0-9][0-9][0-9])-([0-9][0-9])-([0-9][0-9])$", date_string + ) + if match: + y, m, d = int(match.group(1)), int(match.group(2)), int(match.group(3)) + return cls(y, m, d) + raise ValueError(_INVALID_ISO_ERROR.format(date_string)) + @classmethod def today(cls): """Return the current local date.""" @@ -907,6 +924,96 @@ def tzinfo(self): """ return self._tzinfo + @staticmethod + def _parse_iso_string(string_to_parse, segments): + results = [] + + remaining_string = string_to_parse + for regex in segments: + match = _re.match(regex, remaining_string) + if match: + for grp in range(regex.count("(")): + results.append(int(match.group(grp + 1))) + remaining_string = remaining_string[len(match.group(0)) :] + elif remaining_string: # Only raise an error if we're not done yet + raise ValueError() + if remaining_string: + raise ValueError() + return results + + # pylint: disable=too-many-locals + @classmethod + def fromisoformat(cls, time_string): + """Return a time object constructed from an ISO date format. + Valid format is ``HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]`` + + """ + # Store the original string in an error message + original_string = time_string + match = _re.match(r"(.*)[\-\+]", time_string) + offset_string = None + if match: + offset_string = time_string[len(match.group(1)) :] + time_string = match.group(1) + + time_segments = ( + r"([0-9][0-9])", + r":([0-9][0-9])", + r":([0-9][0-9])", + r"\.([0-9][0-9][0-9])", + r"([0-9][0-9][0-9])", + ) + offset_segments = ( + r"([\-\+][0-9][0-9]):([0-9][0-9])", + r":([0-9][0-9])", + r"\.([0-9][0-9][0-9][0-9][0-9][0-9])", + ) + + try: + results = cls._parse_iso_string(time_string, time_segments) + if len(results) < 1: + raise ValueError(_INVALID_ISO_ERROR.format(original_string)) + if len(results) < len(time_segments): + results += [None] * (len(time_segments) - len(results)) + if offset_string: + results += cls._parse_iso_string(offset_string, offset_segments) + except ValueError as error: + raise ValueError(_INVALID_ISO_ERROR.format(original_string)) from error + + hh = results[0] + mm = results[1] if len(results) >= 2 and results[1] is not None else 0 + ss = results[2] if len(results) >= 3 and results[2] is not None else 0 + us = 0 + if len(results) >= 4 and results[3] is not None: + us += results[3] * 1000 + if len(results) >= 5 and results[4] is not None: + us += results[4] + tz = None + if len(results) >= 7: + offset_hh = results[5] + multiplier = -1 if offset_hh < 0 else 1 + offset_mm = results[6] * multiplier + offset_ss = (results[7] if len(results) >= 8 else 0) * multiplier + offset_us = (results[8] if len(results) >= 9 else 0) * multiplier + offset = timedelta( + hours=offset_hh, + minutes=offset_mm, + seconds=offset_ss, + microseconds=offset_us, + ) + tz = timezone(offset, name="utcoffset") + + result = cls( + hh, + mm, + ss, + us, + tz, + ) + return result + + # pylint: enable=too-many-locals + # Instance methods def isoformat(self, timespec="auto"): """Return a string representing the time in ISO 8601 format, one of: @@ -1163,6 +1270,11 @@ def tzinfo(self): """ return self._tzinfo + @property + def fold(self): + """Fold.""" + return self._fold + # Class methods # pylint: disable=protected-access @@ -1206,6 +1318,29 @@ def _fromtimestamp(cls, t, utc, tz): def fromtimestamp(cls, timestamp, tz=None): return cls._fromtimestamp(timestamp, tz is not None, tz) + @classmethod + def fromisoformat(cls, date_string): + """Return a datetime object constructed from an ISO date format. + Valid format is ``YYYY-MM-DD[*HH[:MM[:SS[.fff[fff]]]][+HH:MM[:SS[.ffffff]]]]`` + + """ + original_string = date_string + + time_string = None + try: + if len(date_string) > 10: + time_string = date_string[11:] + date_string = date_string[:10] + dateval = date.fromisoformat(date_string) + timeval = time.fromisoformat(time_string) + else: + dateval = date.fromisoformat(date_string) + timeval = time() + except ValueError as error: + raise ValueError(_INVALID_ISO_ERROR.format(original_string)) from error + + return cls.combine(dateval, timeval) + @classmethod def now(cls, timezone=None): """Return the current local date and time.""" diff --git a/examples/datetime_simpletest.py b/examples/datetime_simpletest.py index 4c42b82..cf97a6b 100644 --- a/examples/datetime_simpletest.py +++ b/examples/datetime_simpletest.py @@ -6,6 +6,7 @@ # All rights reserved. # SPDX-FileCopyrightText: 1991-1995 Stichting Mathematisch Centrum. All rights reserved. # SPDX-FileCopyrightText: 2021 Brent Rubell for Adafruit Industries +# SPDX-FileCopyrightText: 2021 Melissa LeBlanc-Williams for Adafruit Industries # SPDX-License-Identifier: Python-2.0 # Example of working with a `datetime` object @@ -28,3 +29,8 @@ print(it) print("Today is: ", dt.ctime()) + +iso_date_string = "2020-04-05T05:04:45.752301" +print("Creating new datetime from ISO Date:", iso_date_string) +isodate = datetime.fromisoformat(iso_date_string) +print("Formatted back out as ISO Date: ", isodate.isoformat()) diff --git a/tests/test_date.py b/tests/test_date.py index 8b9626c..2e5e09c 100644 --- a/tests/test_date.py +++ b/tests/test_date.py @@ -88,6 +88,19 @@ def test_fromtimestamp(self): self.assertEqual(d.month, month) self.assertEqual(d.day, day) + def test_fromisoformat(self): + # Try an arbitrary fixed value. + iso_date_string = "1999-09-19" + d = cpy_date.fromisoformat(iso_date_string) + self.assertEqual(d.year, 1999) + self.assertEqual(d.month, 9) + self.assertEqual(d.day, 19) + + def test_fromisoformat_bad_formats(self): + # Try an arbitrary fixed value. + self.assertRaises(ValueError, cpy_date.fromisoformat, "99-09-19") + self.assertRaises(ValueError, cpy_date.fromisoformat, "1999-13-19") + # TODO: Test this when timedelta is added in @unittest.skip("Skip for CircuitPython - timedelta() not yet implemented.") def test_today(self): diff --git a/tests/test_datetime.py b/tests/test_datetime.py index a3e90b0..140bd84 100644 --- a/tests/test_datetime.py +++ b/tests/test_datetime.py @@ -1033,8 +1033,6 @@ def __new__(cls, *args, **kwargs): self.assertIsInstance(dt, DateTimeSubclass) self.assertEqual(dt.extra, 7) - # TODO - @unittest.skip("timezone not implemented") def test_fromisoformat_datetime(self): # Test that isoformat() is reversible base_dates = [(1, 1, 1), (1900, 1, 1), (2004, 11, 12), (2017, 5, 30)] @@ -1097,8 +1095,6 @@ def test_fromisoformat_timezone(self): dt_rt = self.theclass.fromisoformat(dtstr) assert dt == dt_rt, dt_rt - # TODO - @unittest.skip("fromisoformat not implemented") def test_fromisoformat_separators(self): separators = [ " ", @@ -1120,8 +1116,6 @@ def test_fromisoformat_separators(self): dt_rt = self.theclass.fromisoformat(dtstr) self.assertEqual(dt, dt_rt) - # TODO - @unittest.skip("fromisoformat not implemented") def test_fromisoformat_ambiguous(self): # Test strings like 2018-01-31+12:15 (where +12:15 is not a time zone) separators = ["+", "-"] @@ -1134,7 +1128,7 @@ def test_fromisoformat_ambiguous(self): self.assertEqual(dt, dt_rt) # TODO - @unittest.skip("fromisoformat not implemented") + @unittest.skip("_format_time not fully implemented") def test_fromisoformat_timespecs(self): datetime_bases = [(2009, 12, 4, 8, 17, 45, 123456), (2009, 12, 4, 8, 17, 45, 0)] @@ -1161,8 +1155,6 @@ def test_fromisoformat_timespecs(self): dt_rt = self.theclass.fromisoformat(dtstr) self.assertEqual(dt, dt_rt) - # TODO - @unittest.skip("fromisoformat not implemented") def test_fromisoformat_fails_datetime(self): # Test that fromisoformat() fails on invalid values bad_strs = [ @@ -1201,14 +1193,12 @@ def test_fromisoformat_fails_datetime(self): with self.assertRaises(ValueError): self.theclass.fromisoformat(bad_str) - # TODO - @unittest.skip("fromisoformat not implemented") def test_fromisoformat_fails_surrogate(self): # Test that when fromisoformat() fails with a surrogate character as # the separator, the error message contains the original string dtstr = "2018-01-03\ud80001:0113" - with self.assertRaisesRegex(ValueError, re.escape(repr(dtstr))): + with self.assertRaisesRegex(ValueError, repr(dtstr)): self.theclass.fromisoformat(dtstr) # TODO @@ -1219,8 +1209,6 @@ def test_fromisoformat_utc(self): self.assertIs(dt.tzinfo, timezone.utc) - # TODO - @unittest.skip("fromisoformat not implemented") def test_fromisoformat_subclass(self): class DateTimeSubclass(self.theclass): pass