Repository: meltano/sdk
Pull request #1365 (2023-01-30 20:34:07)

feat: Add `strptime_to_utc` and `strftime` functions to `_singerlib.utils`
Slack discussion here https://meltano.slack.com/archives/C01PKLU5D1R/p1675106007009959


Context: I am trying to add Python 3.11 support to [tap-tableau](https://github.com/GtheSheep/tap-tableau/pull/4). Currently that tap depends on the sdk version ^0.4.5 — python 3.11 support was added in 0.13.1 so I need to bump the sdk dependency at least that far, but would like to bring it to current 0.19.0 if possible. However, one of the sdk changes made since 0.4.5 was dropping the dependency on singer-python , and this tap depends on that package (transitively via the sdk dependency). It uses that singer package to format a datetime https://github.com/GtheSheep/tap-tableau/blob/main/tap_tableau/utils.py#L6


<!-- readthedocs-preview meltano-sdk start -->
----
:books: Documentation preview :books:: https://meltano-sdk--1365.org.readthedocs.build/en/1365/

<!-- readthedocs-preview meltano-sdk end -->

codecov[bot]: # [Codecov](https://codecov.io/gh/meltano/sdk/pull/1365?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) Report
> Merging [#1365](https://codecov.io/gh/meltano/sdk/pull/1365?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) (7ee2b3a) into [main](https://codecov.io/gh/meltano/sdk/commit/08184b043725cce0ec416800ae5baf89f3261b67?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) (08184b0) will **decrease** coverage by `0.02%`.
> The diff coverage is `81.81%`.

```diff
@@            Coverage Diff             @@
##             main    #1365      +/-   ##
==========================================
- Coverage   85.15%   85.13%   -0.02%     
==========================================
  Files          53       54       +1     
  Lines        4668     4690      +22     
  Branches      794      797       +3     
==========================================
+ Hits         3975     3993      +18     
- Misses        503      505       +2     
- Partials      190      192       +2     
```


| [Impacted Files](https://codecov.io/gh/meltano/sdk/pull/1365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano) | Coverage Δ | |
|---|---|---|
| [singer\_sdk/\_singerlib/utils.py](https://codecov.io/gh/meltano/sdk/pull/1365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano#diff-c2luZ2VyX3Nkay9fc2luZ2VybGliL3V0aWxzLnB5) | `80.95% <80.95%> (ø)` | |
| [singer\_sdk/\_singerlib/\_\_init\_\_.py](https://codecov.io/gh/meltano/sdk/pull/1365?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano#diff-c2luZ2VyX3Nkay9fc2luZ2VybGliL19faW5pdF9fLnB5) | `100.00% <100.00%> (ø)` | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=meltano)


Patch:
diff --git a/singer_sdk/_singerlib/__init__.py b/singer_sdk/_singerlib/__init__.py
index 368ca191..ae97070d 100644
--- a/singer_sdk/_singerlib/__init__.py
+++ b/singer_sdk/_singerlib/__init__.py
@@ -17,6 +17,7 @@ from singer_sdk._singerlib.messages import (
     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 @@ __all__ = [
     "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 00000000..e8f513aa
--- /dev/null
+++ b/singer_sdk/_singerlib/utils.py
@@ -0,0 +1,58 @@
+from datetime import datetime, timedelta
+
+import dateutil.parser
+import pytz
+
+DATETIME_FMT = "%04Y-%m-%dT%H:%M:%S.%fZ"
+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.
+
+    Args:
+        dtimestr: a string representation of a datetime
+
+    Returns:
+        A UTC datetime.datetime object
+    """
+    d_object: datetime = 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:
+        NonUTCDatetimeError: if the datetime is not UTC (if it has a nonzero time zone
+            offset)
+    """
+    if dtime.utcoffset() != timedelta(0):
+        raise NonUTCDatetimeError()
+
+    dt_str = None
+    try:
+        dt_str = dtime.strftime(format_str)
+        if dt_str.startswith("4Y"):
+            dt_str = dtime.strftime(DATETIME_FMT_SAFE)
+    except ValueError:
+        dt_str = dtime.strftime(DATETIME_FMT_SAFE)
+    return dt_str


Test patch:
diff --git a/tests/_singerlib/test_utils.py b/tests/_singerlib/test_utils.py
new file mode 100644
index 00000000..d26ee468
--- /dev/null
+++ b/tests/_singerlib/test_utils.py
@@ -0,0 +1,41 @@
+from datetime import datetime
+
+import pytest
+import pytz
+
+from singer_sdk._singerlib import strftime, strptime_to_utc
+from singer_sdk._singerlib.utils import NonUTCDatetimeError
+
+
+def test_small_years():
+    assert (
+        strftime(datetime(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
+
+
+@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)