diff --git a/src/firebase_functions/firestore_fn.py b/src/firebase_functions/firestore_fn.py index 122a82c..53f281b 100644 --- a/src/firebase_functions/firestore_fn.py +++ b/src/firebase_functions/firestore_fn.py @@ -18,7 +18,6 @@ import dataclasses as _dataclass import functools as _functools import typing as _typing -import datetime as _dt import google.events.cloud.firestore as _firestore import google.cloud.firestore_v1 as _firestore_v1 import firebase_functions.private.util as _util @@ -111,10 +110,14 @@ def _firestore_endpoint_handler( event_namespace = event_attributes["namespace"] event_document = event_attributes["document"] event_database = event_attributes["database"] - event_time = _dt.datetime.strptime( - event_attributes["time"], - "%Y-%m-%dT%H:%M:%S.%f%z", - ) + + time = event_attributes["time"] + is_nanoseconds = _util.is_precision_timestamp(time) + + if is_nanoseconds: + event_time = _util.nanoseconds_timestamp_conversion(time) + else: + event_time = _util.microsecond_timestamp_conversion(time) if _DEFAULT_APP_NAME not in _apps: initialize_app() diff --git a/src/firebase_functions/private/util.py b/src/firebase_functions/private/util.py index 5838fcf..9c521e9 100644 --- a/src/firebase_functions/private/util.py +++ b/src/firebase_functions/private/util.py @@ -19,6 +19,7 @@ import json as _json import typing as _typing import dataclasses as _dataclasses +import datetime as _dt import enum as _enum from flask import Request as _Request from functions_framework import logging as _logging @@ -310,6 +311,42 @@ def firebase_config() -> None | FirebaseConfig: return FirebaseConfig(storage_bucket=json_data.get("storageBucket")) +def nanoseconds_timestamp_conversion(time: str) -> _dt.datetime: + """Converts a nanosecond timestamp and returns a datetime object of the current time in UTC""" + + # Separate the date and time part from the nanoseconds. + datetime_str, nanosecond_str = time.replace("Z", "").replace("z", + "").split(".") + # Parse the date and time part of the string. + event_time = _dt.datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S") + # Add the microseconds and timezone. + event_time = event_time.replace(microsecond=int(nanosecond_str[:6]), + tzinfo=_dt.timezone.utc) + + return event_time + + +def is_precision_timestamp(time: str) -> bool: + """Return a bool which indicates if the timestamp is in nanoseconds""" + # Split the string into date-time and fraction of second + _, s_fraction = time.split(".") + + # Split the fraction from the timezone specifier ('Z' or 'z') + s_fraction, _ = s_fraction.split( + "Z") if "Z" in s_fraction else s_fraction.split("z") + + # If the fraction is more than 6 digits long, it's a nanosecond timestamp + return len(s_fraction) > 6 + + +def microsecond_timestamp_conversion(time: str) -> _dt.datetime: + """Converts a microsecond timestamp and returns a datetime object of the current time in UTC""" + return _dt.datetime.strptime( + time, + "%Y-%m-%dT%H:%M:%S.%f%z", + ) + + def normalize_path(path: str) -> str: """ Normalize a path string to a consistent format. diff --git a/tests/test_util.py b/tests/test_util.py index 8a40f99..355d0c9 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -15,7 +15,8 @@ Internal utils tests. """ from os import environ, path -from firebase_functions.private.util import firebase_config, normalize_path +from firebase_functions.private.util import firebase_config, microsecond_timestamp_conversion, nanoseconds_timestamp_conversion, is_precision_timestamp, normalize_path +import datetime as _dt test_bucket = "python-functions-testing.appspot.com" test_config_file = path.join(path.dirname(path.realpath(__file__)), @@ -42,6 +43,68 @@ def test_firebase_config_loads_from_env_file(): "Failure, firebase_config did not load from env variable.") +def test_microsecond_conversion(): + """ + Testing microsecond_timestamp_conversion works as intended + """ + timestamps = [ + ("2023-06-20T10:15:22.396358Z", "2023-06-20T10:15:22.396358Z"), + ("2021-02-20T11:23:45.987123Z", "2021-02-20T11:23:45.987123Z"), + ("2022-09-18T09:15:38.246824Z", "2022-09-18T09:15:38.246824Z"), + ("2010-09-18T09:15:38.246824Z", "2010-09-18T09:15:38.246824Z"), + ] + + for input_timestamp, expected_output in timestamps: + expected_datetime = _dt.datetime.strptime(expected_output, + "%Y-%m-%dT%H:%M:%S.%fZ") + expected_datetime = expected_datetime.replace(tzinfo=_dt.timezone.utc) + assert microsecond_timestamp_conversion( + input_timestamp) == expected_datetime + + +def test_nanosecond_conversion(): + """ + Testing nanoseconds_timestamp_conversion works as intended + """ + timestamps = [ + ("2023-01-01T12:34:56.123456789Z", "2023-01-01T12:34:56.123456Z"), + ("2023-02-14T14:37:52.987654321Z", "2023-02-14T14:37:52.987654Z"), + ("2023-03-21T06:43:58.564738291Z", "2023-03-21T06:43:58.564738Z"), + ("2023-08-15T22:22:22.222222222Z", "2023-08-15T22:22:22.222222Z"), + ] + + for input_timestamp, expected_output in timestamps: + expected_datetime = _dt.datetime.strptime(expected_output, + "%Y-%m-%dT%H:%M:%S.%fZ") + expected_datetime = expected_datetime.replace(tzinfo=_dt.timezone.utc) + assert nanoseconds_timestamp_conversion( + input_timestamp) == expected_datetime + + +def test_is_nanoseconds_timestamp(): + """ + Testing is_nanoseconds_timestamp works as intended + """ + microsecond_timestamp1 = "2023-06-20T10:15:22.396358Z" + microsecond_timestamp2 = "2021-02-20T11:23:45.987123Z" + microsecond_timestamp3 = "2022-09-18T09:15:38.246824Z" + microsecond_timestamp4 = "2010-09-18T09:15:38.246824Z" + + nanosecond_timestamp1 = "2023-01-01T12:34:56.123456789Z" + nanosecond_timestamp2 = "2023-02-14T14:37:52.987654321Z" + nanosecond_timestamp3 = "2023-03-21T06:43:58.564738291Z" + nanosecond_timestamp4 = "2023-08-15T22:22:22.222222222Z" + + assert is_precision_timestamp(microsecond_timestamp1) is False + assert is_precision_timestamp(microsecond_timestamp2) is False + assert is_precision_timestamp(microsecond_timestamp3) is False + assert is_precision_timestamp(microsecond_timestamp4) is False + assert is_precision_timestamp(nanosecond_timestamp1) is True + assert is_precision_timestamp(nanosecond_timestamp2) is True + assert is_precision_timestamp(nanosecond_timestamp3) is True + assert is_precision_timestamp(nanosecond_timestamp4) is True + + def test_normalize_document_path(): """ Testing "document" path passed to Firestore event listener