Skip to content
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