Repository: microsoft/electionguard-python

Issue:
✨ Deserialize ISO-8601 dates with `Z`
We're using `isoformat` and `fromisoformat` to serialize/deserialize datetimes, [which unfortunately doesn't handle the `Z` formatting for UTC](https://stackoverflow.com/questions/19654578/python-utc-datetime-objects-iso-format-doesnt-include-z-zulu-or-zero-offset).

When deserializing datetimes, we would like **both** offsets (e.g. `-05:00`) and `Z` to be supported.


Discussion:

Patch:
diff --git a/src/electionguard/serializable.py b/src/electionguard/serializable.py
index 25ce0d5..e22cedf 100644
--- a/src/electionguard/serializable.py
+++ b/src/electionguard/serializable.py
@@ -1,5 +1,6 @@
 from dataclasses import dataclass
 from datetime import datetime
+import re
 from os import path
 from typing import Any, cast, Type, TypeVar
 
@@ -236,4 +237,14 @@ def set_deserializers() -> None:
         NoneType,
     )
 
-    set_deserializer(lambda dt, cls, **_: datetime.fromisoformat(dt), datetime)
+    set_deserializer(lambda dt, cls, **_: _deserialize_datetime(dt), datetime)
+
+
+def _deserialize_datetime(value: str) -> datetime:
+    """
+    The `fromisoformat` function doesn't recognize the Z (Zulu) suffix
+    to indicate UTC.  For compatibility with more external clients, we
+    should allow it.
+    """
+    tz_corrected = re.sub("Z$", "+00:00", value)
+    return datetime.fromisoformat(tz_corrected)


Test patch:
diff --git a/tests/test_serializable.py b/tests/test_serializable.py
index c4a2682..1c86bf0 100644
--- a/tests/test_serializable.py
+++ b/tests/test_serializable.py
@@ -1,4 +1,5 @@
 from dataclasses import dataclass
+from datetime import datetime, timezone
 from unittest import TestCase
 from typing import Any, List, Optional
 from os import remove
@@ -30,21 +31,48 @@ class DataModel:
     test: int
     nested: NestedModel
     array: List[NestedModel]
+    datetime: datetime
     from_json_file: Optional[Any] = None
 
 
 JSON_DATA: DataModel = DataModel(
-    test=1, nested=NestedModel(test=1), array=[NestedModel(test=1)]
+    test=1,
+    nested=NestedModel(test=1),
+    datetime=datetime(2020, 9, 28, 20, 11, 31, tzinfo=timezone.utc),
+    array=[NestedModel(test=1)],
 )
-EXPECTED_JSON_STRING = '{"array": [{"test": 1}], "nested": {"test": 1}, "test": 1}'
+EXPECTED_JSON_STRING = '{"array": [{"test": 1}], "datetime": "2020-09-28T20:11:31+00:00", "nested": {"test": 1}, "test": 1}'
 EXPECTED_JSON_OBJECT = {
     "test": 1,
+    "datetime": "2020-09-28T20:11:31+00:00",
     "nested": {"test": 1},
     "array": [{"test": 1}],
 }
 
 
 class TestSerializable(TestCase):
+    def test_read_iso_date(self) -> None:
+        # Arrange
+        target_date = datetime(2020, 9, 28, 20, 11, 31, tzinfo=timezone.utc)
+        representations = [
+            # UTC
+            '"2020-09-28T20:11:31+00:00"',
+            '"2020-09-28T20:11:31.000+00:00"',
+            '"2020-09-28T20:11:31.000Z"',
+            '"2020-09-28T20:11:31Z"',
+            # Other time zone
+            '"2020-09-28T21:11:31+01:00"',
+            '"2020-09-28T21:11:31.000+01:00"',
+        ]
+
+        # Act
+        results = [read_json(value, datetime) for value in representations]
+
+        # Assert
+        # expected_timestamp = target_date.timestamp()
+        for result in results:
+            self.assertEqual(target_date, result)
+
     def test_read_and_write_json(self) -> None:
         # Act
         json_string = write_json(JSON_DATA)