From fdf7ab7a5bde9b5d9ee3dc51a38383953a50659e Mon Sep 17 00:00:00 2001 From: Matt Menzenski Date: Mon, 30 Jan 2023 14:31:59 -0600 Subject: [PATCH 1/5] Add two date functions to _singerlib.utils --- singer_sdk/_singerlib/__init__.py | 3 ++ singer_sdk/_singerlib/utils.py | 47 +++++++++++++++++++++++++++++++ tests/_singerlib/test_utils.py | 18 ++++++++++++ 3 files changed, 68 insertions(+) create mode 100644 singer_sdk/_singerlib/utils.py create mode 100644 tests/_singerlib/test_utils.py diff --git a/singer_sdk/_singerlib/__init__.py b/singer_sdk/_singerlib/__init__.py index 368ca191a..ae97070de 100644 --- a/singer_sdk/_singerlib/__init__.py +++ b/singer_sdk/_singerlib/__init__.py @@ -17,6 +17,7 @@ write_message, ) from singer_sdk._singerlib.schema import Schema, resolve_schema_references +from singer_sdk._singerlib.utils import strftime, strptime_to_utc __all__ = [ "Catalog", @@ -35,4 +36,6 @@ "write_message", "Schema", "resolve_schema_references", + "strftime", + "strptime_to_utc", ] diff --git a/singer_sdk/_singerlib/utils.py b/singer_sdk/_singerlib/utils.py new file mode 100644 index 000000000..bf68e9d21 --- /dev/null +++ b/singer_sdk/_singerlib/utils.py @@ -0,0 +1,47 @@ +import datetime + +import dateutil.parser +import pytz + +DATETIME_FMT = "%04Y-%m-%dT%H:%M:%S.%fZ" +DATETIME_FMT_SAFE = "%Y-%m-%dT%H:%M:%S.%fZ" + + +def strptime_to_utc(dtimestr: str) -> datetime: + """Parses a provide datetime string into a UTC datetime object. + + Args: + dtimestr: a string representation of a datetime + + Returns: + A UTC datetime object + """ + d_object = dateutil.parser.parse(dtimestr) + if d_object.tzinfo is None: + return d_object.replace(tzinfo=pytz.UTC) + else: + return d_object.astimezone(tz=pytz.UTC) + + +def strftime(dtime: datetime, format_str: str = DATETIME_FMT) -> str: + """Formats a provided datetime object as a string. + + Args: + dtime: a datetime + format_str: output format specification + + Returns: + A string in the specified format + + Raises: + Exception: if the datetime is not UTC (if it has a nonzero time zone offset) + """ + if dtime.utcoffset() != datetime.timedelta(0): + raise Exception("datetime must be pegged at UTC tzoneinfo") + + try: + dt_str = dtime.strftime(format_str) + if dt_str.startswith("4Y"): + return dtime.strftime(DATETIME_FMT_SAFE) + except ValueError: + return dtime.strftime(DATETIME_FMT_SAFE) diff --git a/tests/_singerlib/test_utils.py b/tests/_singerlib/test_utils.py new file mode 100644 index 000000000..7c6854271 --- /dev/null +++ b/tests/_singerlib/test_utils.py @@ -0,0 +1,18 @@ +from datetime import datetime + +import pytest +import pytz + +from singer_sdk._singerlib import strftime, strptime_to_utc + + +def test_small_years(): + assert strftime(dt(90, 1, 1, tzinfo=pytz.UTC)) == "0090-01-01T00:00:00.000000Z" + + +def test_round_trip(): + now = datetime.utcnow().replace(tzinfo=pytz.UTC) + dtime = strftime(now) + parsed_datetime = strptime_to_utc(dtime) + formatted_datetime = strftime(parsed_datetime) + assert dtime == formatted_datetime From fd22052d3fd0f3d03c608e79246d4fa1b3f276b1 Mon Sep 17 00:00:00 2001 From: Matt Menzenski Date: Mon, 30 Jan 2023 14:37:10 -0600 Subject: [PATCH 2/5] fix typing --- singer_sdk/_singerlib/utils.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/singer_sdk/_singerlib/utils.py b/singer_sdk/_singerlib/utils.py index bf68e9d21..3ef77543b 100644 --- a/singer_sdk/_singerlib/utils.py +++ b/singer_sdk/_singerlib/utils.py @@ -1,4 +1,4 @@ -import datetime +from datetime import datetime, timedelta import dateutil.parser import pytz @@ -14,9 +14,9 @@ def strptime_to_utc(dtimestr: str) -> datetime: dtimestr: a string representation of a datetime Returns: - A UTC datetime object + A UTC datetime.datetime object """ - d_object = dateutil.parser.parse(dtimestr) + d_object: datetime = dateutil.parser.parse(dtimestr) if d_object.tzinfo is None: return d_object.replace(tzinfo=pytz.UTC) else: @@ -36,7 +36,7 @@ def strftime(dtime: datetime, format_str: str = DATETIME_FMT) -> str: Raises: Exception: if the datetime is not UTC (if it has a nonzero time zone offset) """ - if dtime.utcoffset() != datetime.timedelta(0): + if dtime.utcoffset() != timedelta(0): raise Exception("datetime must be pegged at UTC tzoneinfo") try: From fbc27a7c4f6d9266b1d6c3787c2c7fe8381f21b4 Mon Sep 17 00:00:00 2001 From: Matt Menzenski Date: Mon, 30 Jan 2023 14:51:06 -0600 Subject: [PATCH 3/5] whoops, fix dt->datetime --- tests/_singerlib/test_utils.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/_singerlib/test_utils.py b/tests/_singerlib/test_utils.py index 7c6854271..e1035b63c 100644 --- a/tests/_singerlib/test_utils.py +++ b/tests/_singerlib/test_utils.py @@ -7,7 +7,9 @@ def test_small_years(): - assert strftime(dt(90, 1, 1, tzinfo=pytz.UTC)) == "0090-01-01T00:00:00.000000Z" + assert ( + strftime(datetime(90, 1, 1, tzinfo=pytz.UTC)) == "0090-01-01T00:00:00.000000Z" + ) def test_round_trip(): From 7ee2b3aacad0e1834ad1244f5462bfe6006ae596 Mon Sep 17 00:00:00 2001 From: Matt Menzenski Date: Mon, 30 Jan 2023 21:04:42 -0600 Subject: [PATCH 4/5] Correct control flow in strftime --- singer_sdk/_singerlib/utils.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/singer_sdk/_singerlib/utils.py b/singer_sdk/_singerlib/utils.py index 3ef77543b..c1055f75a 100644 --- a/singer_sdk/_singerlib/utils.py +++ b/singer_sdk/_singerlib/utils.py @@ -39,9 +39,11 @@ def strftime(dtime: datetime, format_str: str = DATETIME_FMT) -> str: if dtime.utcoffset() != timedelta(0): raise Exception("datetime must be pegged at UTC tzoneinfo") + dt_str = None try: dt_str = dtime.strftime(format_str) if dt_str.startswith("4Y"): - return dtime.strftime(DATETIME_FMT_SAFE) + dt_str = dtime.strftime(DATETIME_FMT_SAFE) except ValueError: - return dtime.strftime(DATETIME_FMT_SAFE) + dt_str = dtime.strftime(DATETIME_FMT_SAFE) + return dt_str From 99da90fefc6333eb893921d435b60dd9a48db9e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Rami=CC=81rez=20Mondrago=CC=81n?= Date: Tue, 31 Jan 2023 13:19:37 -0600 Subject: [PATCH 5/5] Increase test coverage --- singer_sdk/_singerlib/utils.py | 13 +++++++++++-- tests/_singerlib/test_utils.py | 21 +++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/singer_sdk/_singerlib/utils.py b/singer_sdk/_singerlib/utils.py index c1055f75a..e8f513aa7 100644 --- a/singer_sdk/_singerlib/utils.py +++ b/singer_sdk/_singerlib/utils.py @@ -7,6 +7,14 @@ DATETIME_FMT_SAFE = "%Y-%m-%dT%H:%M:%S.%fZ" +class NonUTCDatetimeError(Exception): + """Raised when a non-UTC datetime is passed to a function expecting UTC.""" + + def __init__(self) -> None: + """Initialize the exception.""" + super().__init__("datetime must be pegged at UTC tzoneinfo") + + def strptime_to_utc(dtimestr: str) -> datetime: """Parses a provide datetime string into a UTC datetime object. @@ -34,10 +42,11 @@ def strftime(dtime: datetime, format_str: str = DATETIME_FMT) -> str: A string in the specified format Raises: - Exception: if the datetime is not UTC (if it has a nonzero time zone offset) + NonUTCDatetimeError: if the datetime is not UTC (if it has a nonzero time zone + offset) """ if dtime.utcoffset() != timedelta(0): - raise Exception("datetime must be pegged at UTC tzoneinfo") + raise NonUTCDatetimeError() dt_str = None try: diff --git a/tests/_singerlib/test_utils.py b/tests/_singerlib/test_utils.py index e1035b63c..d26ee4688 100644 --- a/tests/_singerlib/test_utils.py +++ b/tests/_singerlib/test_utils.py @@ -4,6 +4,7 @@ import pytz from singer_sdk._singerlib import strftime, strptime_to_utc +from singer_sdk._singerlib.utils import NonUTCDatetimeError def test_small_years(): @@ -18,3 +19,23 @@ def test_round_trip(): parsed_datetime = strptime_to_utc(dtime) formatted_datetime = strftime(parsed_datetime) assert dtime == formatted_datetime + + +@pytest.mark.parametrize( + "dtimestr", + [ + "2021-01-01T00:00:00.000000Z", + "2021-01-01T00:00:00.000000+00:00", + "2021-01-01T00:00:00.000000+06:00", + "2021-01-01T00:00:00.000000-04:00", + ], + ids=["Z", "offset+0", "offset+6", "offset-4"], +) +def test_strptime_to_utc(dtimestr): + assert strptime_to_utc(dtimestr).tzinfo == pytz.UTC + + +def test_stftime_non_utc(): + now = datetime.utcnow().replace(tzinfo=pytz.timezone("America/New_York")) + with pytest.raises(NonUTCDatetimeError): + strftime(now)