Skip to content

Commit

Permalink
Improve StringBasedDateTime and StringBasedTimeDelta (#6)
Browse files Browse the repository at this point in the history
  • Loading branch information
BenjaminPelletier authored Feb 8, 2023
1 parent e58c91e commit c0efa3e
Show file tree
Hide file tree
Showing 3 changed files with 204 additions and 9 deletions.
53 changes: 44 additions & 9 deletions src/implicitdict/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import arrow
import datetime
from datetime import datetime as datetime_type
from typing import get_args, get_origin, get_type_hints, Dict, Literal, \
Optional, Type, Union, Set, Tuple

Expand Down Expand Up @@ -279,23 +280,57 @@ def _fullname(class_type: Type) -> str:

class StringBasedTimeDelta(str):
"""String that only allows values which describe a timedelta."""
def __new__(cls, value):

timedelta: datetime.timedelta
"""Timedelta matching the string value of this instance."""

def __new__(cls, value: Union[str, datetime.timedelta, int, float], reformat: bool = False):
"""Create a new StringBasedTimeDelta.
Args:
value: Timedelta representation. May be a pytimeparse-compatible string, Python timedelta, or number of
seconds (float).
reformat: If true, override a provided string with a string representation of the parsed timedelta.
"""
if isinstance(value, str):
dt = datetime.timedelta(seconds=pytimeparse.parse(value))
else:
s = str(dt) if reformat else value
elif isinstance(value, float) or isinstance(value, int):
dt = datetime.timedelta(seconds=value)
s = f'{value}s'
elif isinstance(value, datetime.timedelta):
dt = value
str_value = str.__new__(cls, str(dt))
s = str(dt)
else:
raise ValueError(f'Could not parse type {type(value).__name__} into StringBasedTimeDelta')
str_value = str.__new__(cls, s)
str_value.timedelta = dt
return str_value


class StringBasedDateTime(str):
"""String that only allows values which describe a datetime."""
def __new__(cls, value):
"""String that only allows values which describe an absolute datetime."""

datetime: datetime.datetime
"""Timezone-aware datetime matching the string value of this instance."""

def __new__(cls, value: Union[str, datetime_type, arrow.Arrow], reformat: bool = False):
"""Create a new StringBasedDateTime instance.
Args:
value: Datetime representation. May be an ISO/RFC3339-compatible string, datetime, or arrow. If timezone
is not specified, UTC will be assumed.
reformat: If true, override a provided string with a string representation of the parsed datetime.
"""
t_arrow = arrow.get(value)
if isinstance(value, str):
t = arrow.get(value).datetime
s = t_arrow.isoformat() if reformat else value
zuluize = reformat
else:
t = value
str_value = str.__new__(cls, arrow.get(t).to('UTC').format('YYYY-MM-DDTHH:mm:ss.SSSSSS') + 'Z')
str_value.datetime = t
s = t_arrow.isoformat()
zuluize = True
if zuluize and s.endswith('+00:00'):
s = s[0:-len('+00:00')] + 'Z'
str_value = str.__new__(cls, s)
str_value.datetime = t_arrow.datetime
return str_value
80 changes: 80 additions & 0 deletions tests/test_string_based_date_time.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from datetime import datetime, timedelta

import arrow
import pytest

from implicitdict import StringBasedDateTime


ZERO_SKEW = timedelta(microseconds=0)


def test_consistency():
t = datetime.utcnow()
sbdt = StringBasedDateTime(t)
with pytest.raises(TypeError): # can't subtract offset-naive and offset-aware datetimes
assert abs(sbdt.datetime - t) <= ZERO_SKEW
with pytest.raises(TypeError): # can't compare offset-naive and offset-aware datetimes
assert not (sbdt.datetime > t)
assert abs(sbdt.datetime - arrow.get(t).datetime) <= ZERO_SKEW

sbdt = StringBasedDateTime(t.isoformat())
assert abs(sbdt.datetime - arrow.get(t).datetime) <= ZERO_SKEW

t = arrow.utcnow().datetime
sbdt = StringBasedDateTime(t)
assert abs(sbdt.datetime - t) <= ZERO_SKEW

sbdt = StringBasedDateTime(t.isoformat())
assert abs(sbdt.datetime - t) <= ZERO_SKEW

t = arrow.now('US/Pacific').datetime
sbdt = StringBasedDateTime(t)
assert abs(sbdt.datetime - t) <= ZERO_SKEW

sbdt = StringBasedDateTime(t.isoformat())
assert abs(sbdt.datetime - t) <= ZERO_SKEW

t = arrow.now('US/Pacific')
sbdt = StringBasedDateTime(t)
assert abs(sbdt.datetime - t) <= ZERO_SKEW

sbdt = StringBasedDateTime(t.isoformat())
assert abs(sbdt.datetime - t) <= ZERO_SKEW

t = arrow.get("1234-01-23T12:34:56.12345678")
sbdt = StringBasedDateTime(t)
assert "12345678" not in sbdt # arrow only stores (after rounding) integer microseconds

with pytest.raises(ValueError): # unconverted data remains (datetime only parses down to microseconds)
datetime.strptime("1234-01-23T12:34:56.12345678", "%Y-%m-%dT%H:%M:%S.%f")


def test_non_mutation():
"""When a string is provided, expect the string representation to remain the same unless reformatting."""

s = "2022-02-01T01:01:00.123456789123456789123456789"
assert StringBasedDateTime(s) == s
sbdt = StringBasedDateTime(s, reformat=True)
assert sbdt != s
assert sbdt.endswith('Z')

s = "1800-12-01T18:15:00"
assert StringBasedDateTime(s) == s
sbdt = StringBasedDateTime(s, reformat=True)
assert sbdt != s
assert sbdt.endswith('Z')

s = "2022-06-23T00:00:00+00:00"
assert StringBasedDateTime(s) == s
sbdt = StringBasedDateTime(s, reformat=True)
assert sbdt != s
assert sbdt.endswith('Z')


def test_zulu_default():
"""When a non-string datetime is provided, expect the string representation to use Z as the UTC timezone."""

assert StringBasedDateTime(datetime.utcnow()).endswith('Z')
assert StringBasedDateTime(arrow.utcnow().datetime).endswith('Z')
assert StringBasedDateTime(arrow.utcnow()).endswith('Z')
80 changes: 80 additions & 0 deletions tests/test_string_based_time_delta.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
from datetime import timedelta

import pytest

from implicitdict import StringBasedTimeDelta


def test_behavior_strings():
s = '1s'
sbtd = StringBasedTimeDelta(s)
assert sbtd == s
assert sbtd.timedelta.total_seconds() == 1

sbtd = StringBasedTimeDelta(s, reformat=True)
assert sbtd != s
assert sbtd.timedelta.total_seconds() == 1

s = '1.1s'
sbtd = StringBasedTimeDelta(s)
assert sbtd == s
assert sbtd.timedelta.total_seconds() == 1.1

sbtd = StringBasedTimeDelta(s, reformat=True)
assert sbtd != s
assert sbtd.timedelta.total_seconds() == 1.1

s = '1m'
sbtd = StringBasedTimeDelta(s)
assert sbtd == s
assert sbtd.timedelta.total_seconds() == 60

sbtd = StringBasedTimeDelta(s, reformat=True)
assert sbtd != s
assert sbtd.timedelta.total_seconds() == 60

s = '5 hours, 34 minutes, 56 seconds'
sbtd = StringBasedTimeDelta(s)
assert sbtd == s
assert sbtd.timedelta.total_seconds() == 5 * 60 * 60 + 34 * 60 + 56

sbtd = StringBasedTimeDelta(s, reformat=True)
assert sbtd != s
assert sbtd.timedelta.total_seconds() == 5 * 60 * 60 + 34 * 60 + 56

s = '0.1234567s'
sbtd = StringBasedTimeDelta(s)
assert sbtd == s
assert '1234567' not in str(sbtd.timedelta.total_seconds()) # timedelta only stores integer microseconds

sbtd = StringBasedTimeDelta(s, reformat=True)
assert sbtd != s


def test_behavior_seconds():
for s in (1, 1.1, 0.5, 0.123456):
sbtd = StringBasedTimeDelta(s)
assert sbtd.endswith('s')
assert sbtd.timedelta.total_seconds() == s

sbtd = StringBasedTimeDelta(0.1234567)
assert '1234567' not in str(sbtd.timedelta.total_seconds()) # timedelta only stores integer microseconds


def test_behavior_timedelta():
deltas = (
timedelta(seconds=1),
timedelta(seconds=1.1),
timedelta(seconds=0.9),
timedelta(minutes=5),
timedelta(hours=5, minutes=34, seconds=56),
timedelta(hours=5, minutes=34, seconds=56, milliseconds=1234),
)
for dt in deltas:
sbtd = StringBasedTimeDelta(dt)
assert sbtd.timedelta == dt
s = str(sbtd)
with pytest.raises(AttributeError): # 'str' object has no attribute 'timedelta'
assert s.timedelta == dt
sbtd2 = StringBasedTimeDelta(s)
assert sbtd2.timedelta == dt

0 comments on commit c0efa3e

Please sign in to comment.