From c0efa3eba61d590d2e64c71611134620dcbeab18 Mon Sep 17 00:00:00 2001 From: Benjamin Pelletier Date: Wed, 8 Feb 2023 08:37:24 -0800 Subject: [PATCH] Improve StringBasedDateTime and StringBasedTimeDelta (#6) --- src/implicitdict/__init__.py | 53 +++++++++++++++--- tests/test_string_based_date_time.py | 80 +++++++++++++++++++++++++++ tests/test_string_based_time_delta.py | 80 +++++++++++++++++++++++++++ 3 files changed, 204 insertions(+), 9 deletions(-) create mode 100644 tests/test_string_based_date_time.py create mode 100644 tests/test_string_based_time_delta.py diff --git a/src/implicitdict/__init__.py b/src/implicitdict/__init__.py index 81a1383..6922eed 100644 --- a/src/implicitdict/__init__.py +++ b/src/implicitdict/__init__.py @@ -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 @@ -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 diff --git a/tests/test_string_based_date_time.py b/tests/test_string_based_date_time.py new file mode 100644 index 0000000..2f5cde6 --- /dev/null +++ b/tests/test_string_based_date_time.py @@ -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') diff --git a/tests/test_string_based_time_delta.py b/tests/test_string_based_time_delta.py new file mode 100644 index 0000000..38ef379 --- /dev/null +++ b/tests/test_string_based_time_delta.py @@ -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