Skip to content

Added fromisoformat() to date and datetime #2

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Feb 19, 2021
135 changes: 135 additions & 0 deletions adafruit_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -1163,6 +1270,11 @@ def tzinfo(self):
"""
return self._tzinfo

@property
def fold(self):
"""Fold."""
return self._fold

# Class methods

# pylint: disable=protected-access
Expand Down Expand Up @@ -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."""
Expand Down
6 changes: 6 additions & 0 deletions examples/datetime_simpletest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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())
13 changes: 13 additions & 0 deletions tests/test_date.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
16 changes: 2 additions & 14 deletions tests/test_datetime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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 = [
" ",
Expand All @@ -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 = ["+", "-"]
Expand All @@ -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)]

Expand All @@ -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 = [
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down