Repository: omarryhan/aiogoogle

Issue:
Token expiry check broken in Python 3.6 and lower
I am not entirely sure if this is a bug or if there simply is no intention to support Python 3.6, but given I was able to `pip install` it into my Python 3.6.6 environment I suspect the former, so here we go:

[Line 25 of `auth.utils`](https://github.com/omarryhan/aiogoogle/blob/master/aiogoogle/auth/utils.py#L25) will not work for Python 3.6 and lower, as `.fromisoformat` was introduced to the `datetime` module in version 3.7. You'd need to use something like `.strptime(expires_at, '%Y-%m-%dT%H:%M:%S.%f')` instead for lower versions

I can submit a PR for this if it is deemed a bug indeed

Comments:

Patch:
diff --git a/aiogoogle/auth/utils.py b/aiogoogle/auth/utils.py
index bed0c05..aa291ae 100644
--- a/aiogoogle/auth/utils.py
+++ b/aiogoogle/auth/utils.py
@@ -3,6 +3,9 @@ __all__ = ["create_secret"]
 import hashlib
 import os
 import datetime
+import sys
+
+from ..utils import _parse_isoformat
 
 
 def create_secret(bytes_length=1024):  # pragma: no cover
@@ -22,7 +25,11 @@ def _is_expired(expires_at):
     if expires_at is None:
         return True
     if not isinstance(expires_at, datetime.datetime):
-        expires_at = datetime.datetime.fromisoformat(expires_at)
+        # datetime.fromisoformat is 3.7+
+        if sys.version_info[1] <= 6:
+            expires_at = _parse_isoformat(expires_at)
+        else:
+            expires_at = datetime.fromisoformat(expires_at)
     if datetime.datetime.utcnow() >= expires_at:
         return True
     else:
diff --git a/aiogoogle/utils.py b/aiogoogle/utils.py
index fe637f3..5f99587 100644
--- a/aiogoogle/utils.py
+++ b/aiogoogle/utils.py
@@ -1,5 +1,8 @@
 __all__ = []
 
+import datetime
+import re
+
 
 def _safe_getitem(dct, *keys):
     for key in keys:
@@ -40,3 +43,59 @@ class _dict(dict):  # pragma: no cover
     def __delitem__(self, key):  # pragma: no cover
         super(_dict, self).__delitem__(key)
         del self.__dict__[key]
+
+
+def _parse_time_components(tstr):
+    # supported format is HH[:MM[:SS[.fff[fff]]]]
+    if len(tstr) < 2:
+        raise ValueError("Invalid Isotime format")
+    hh = tstr[:2]
+    mm_ss = re.findall(r":(\d{2})", tstr)
+    ff = re.findall(r"\.(\d+)", tstr)
+    if ff and not len(ff[0]) in [3, 6]:
+        raise ValueError("Invalid Isotime format")
+    ff = ff[0] if ff else []
+
+    # ensure tstr was valid
+    if len(mm_ss) < 2 and ff:
+        raise ValueError("Invalid Isotime format")
+    parsed_str = hh + (":" + ":".join(mm_ss) if mm_ss else "") + \
+        ("." + ff if ff else "")
+    if parsed_str != tstr:
+        raise ValueError("Invalid Isotime format")
+    components = [int(hh)]
+    if mm_ss:
+        components.extend(int(t) for t in mm_ss)
+    if ff:
+        components.append(int(ff.ljust(6, "0")))
+    return components + [0] * (4 - len(components))
+
+
+def _parse_isoformat(dtstr):
+    # supported format is YYYY-mm-dd[THH[:MM[:SS[.fff[fff]]]]][+HH:MM[:SS[.ffffff]]]
+    dstr = dtstr[:10]
+    tstr = dtstr[11:]
+    try:
+        date = datetime.datetime.strptime(dstr, "%Y-%m-%d")
+    except ValueError as e:
+        raise ValueError("Invalid Isotime format") from e
+
+    if tstr:
+        # check for time zone
+        tz_pos = (tstr.find("-") + 1 or tstr.find("+") + 1)
+        if tz_pos > 0:
+            tzsign = -1 if tstr[tz_pos - 1] == "-" else 1
+            tz_comps = _parse_time_components(tstr[tz_pos:])
+            tz = tzsign * datetime.timedelta(
+                hours=tz_comps[0], minutes=tz_comps[1],
+                seconds=tz_comps[2], microseconds=tz_comps[3])
+            tstr = tstr[:tz_pos - 1]
+        else:
+            tz = datetime.timedelta(0)
+        time_comps = _parse_time_components(tstr)
+        date = date.replace(hour=time_comps[0], minute=time_comps[1],
+                            second=time_comps[2], microsecond=time_comps[3])
+        date -= tz
+    elif len(dtstr) == 11:
+        raise ValueError("Invalid Isotime format")
+    return date


Test patch:
diff --git a/tests/test_units/test_dateutil.py b/tests/test_units/test_dateutil.py
new file mode 100644
index 0000000..ebd91db
--- /dev/null
+++ b/tests/test_units/test_dateutil.py
@@ -0,0 +1,66 @@
+import pytest
+
+from datetime import datetime
+from aiogoogle.utils import _parse_isoformat
+
+
+def test_iso_parser1():
+    assert _parse_isoformat("2021-02-06") == datetime(2021, 2, 6)
+
+
+def test_iso_parser2():
+    assert _parse_isoformat("2021-02-06T14") == datetime(2021, 2, 6, 14)
+
+
+def test_iso_parser3():
+    assert _parse_isoformat("2021-02-06T14:52") == datetime(2021, 2, 6, 14, 52)
+
+
+def test_iso_parser4():
+    assert _parse_isoformat("2021-02-06T14:52:26") == \
+        datetime(2021, 2, 6, 14, 52, 26)
+
+
+def test_iso_parser5():
+    assert _parse_isoformat("2021-02-06T14:52:26.123") == \
+        datetime(2021, 2, 6, 14, 52, 26, 123000)
+
+
+def test_iso_parser6():
+    assert _parse_isoformat("2021-02-06T14:52:26.123456") == \
+        datetime(2021, 2, 6, 14, 52, 26, 123456)
+
+
+def test_iso_parser7():
+    assert _parse_isoformat("2021-02-06T14:52:26.123456+01:30") == \
+        datetime(2021, 2, 6, 13, 22, 26, 123456)
+
+
+def test_iso_parser8():
+    assert _parse_isoformat("2021-02-06T14:52:26.123456-01:00") == \
+        datetime(2021, 2, 6, 15, 52, 26, 123456)
+
+
+def test_iso_malformed1():
+    with pytest.raises(ValueError):
+        _parse_isoformat("2021-02T14:52")
+
+
+def test_iso_malformed2():
+    with pytest.raises(ValueError):
+        _parse_isoformat("2021-02-06T")
+
+
+def test_iso_malformed3():
+    with pytest.raises(ValueError):
+        _parse_isoformat("2021-02-06T14.123")
+
+
+def test_iso_malformed4():
+    with pytest.raises(ValueError):
+        _parse_isoformat("2021-02-06T14:52.123456")
+
+
+def test_iso_malformed5():
+    with pytest.raises(ValueError):
+        _parse_isoformat("2021-02-06T14:52:26.1234")