Skip to content

Commit 4c53088

Browse files
committed
fix: simplify timestamp parsing
1 parent 9632e9c commit 4c53088

2 files changed

Lines changed: 32 additions & 154 deletions

File tree

src/firebase_functions/private/util.py

Lines changed: 19 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -348,79 +348,33 @@ def firebase_config() -> None | FirebaseConfig:
348348
return FirebaseConfig(storage_bucket=json_data.get("storageBucket"))
349349

350350

351-
def nanoseconds_timestamp_conversion(time: str) -> _dt.datetime:
352-
"""Converts a nanosecond timestamp and returns a datetime object of the current time in UTC"""
351+
def normalize_timestamp_string(time: str) -> str:
352+
"""Truncate sub-second precision to microseconds for standard datetime parsing."""
353+
# Python's %z parser accepts uppercase "Z" for UTC, but not lowercase "z".
354+
if time.endswith("z"):
355+
time = time[:-1] + "Z"
353356

354-
# Separate the date and time part from the nanoseconds.
355-
datetime_str, nanosecond_str = time.replace("Z", "").replace("z", "").split(".")
356-
# Parse the date and time part of the string.
357-
event_time = _dt.datetime.strptime(datetime_str, "%Y-%m-%dT%H:%M:%S")
358-
# Add the microseconds and timezone.
359-
event_time = event_time.replace(microsecond=int(nanosecond_str[:6]), tzinfo=_dt.timezone.utc)
360-
361-
return event_time
362-
363-
364-
def second_timestamp_conversion(time: str) -> _dt.datetime:
365-
"""Converts a second timestamp and returns a datetime object of the current time in UTC"""
366-
return _dt.datetime.strptime(
367-
time,
368-
"%Y-%m-%dT%H:%M:%S%z",
369-
)
370-
371-
372-
class PrecisionTimestamp(_enum.Enum):
373-
"""
374-
Timestamp precision levels supported by Firebase event timestamp parsing.
375-
"""
376-
377-
NANOSECONDS = "NANOSECONDS"
378-
379-
MICROSECONDS = "MICROSECONDS"
380-
381-
SECONDS = "SECONDS"
382-
383-
def __str__(self) -> str:
384-
return self.value
385-
386-
387-
def get_precision_timestamp(time: str) -> PrecisionTimestamp:
388-
"""Return the precision used by a Firebase event timestamp."""
357+
# Whole-second timestamps already fit the standard parser.
389358
if "." not in time:
390-
return PrecisionTimestamp.SECONDS
359+
return time
391360

392-
_, s_fraction = time.split(".", 1)
393-
if not (fraction_match := _re.match(r"\d+", s_fraction)):
361+
# Split once so the suffix still contains both fraction and timezone.
362+
prefix, suffix = time.split(".", 1)
363+
# The suffix must start with fractional second digits, followed by a timezone.
364+
if not (fraction_match := _re.match(r"(\d+)(.*)", suffix)):
394365
raise ValueError(f"Invalid timestamp format: {time}")
395-
s_fraction = fraction_match.group()
396366

397-
# If the fraction is more than 6 digits long, it's a nanosecond timestamp
398-
if len(s_fraction) > 6:
399-
return PrecisionTimestamp.NANOSECONDS
400-
else:
401-
return PrecisionTimestamp.MICROSECONDS
367+
# datetime only stores microseconds, so keep the first 6 fractional digits.
368+
digits, timezone = fraction_match.groups()
369+
return f"{prefix}.{digits[:6]}{timezone}"
402370

403371

404372
def timestamp_conversion(time: str) -> _dt.datetime:
405-
"""Converts a timestamp and returns a datetime object of the current time in UTC"""
406-
precision_timestamp = get_precision_timestamp(time)
407-
408-
if precision_timestamp == PrecisionTimestamp.NANOSECONDS:
409-
return nanoseconds_timestamp_conversion(time)
410-
elif precision_timestamp == PrecisionTimestamp.MICROSECONDS:
411-
return microsecond_timestamp_conversion(time)
412-
elif precision_timestamp == PrecisionTimestamp.SECONDS:
413-
return second_timestamp_conversion(time)
414-
415-
raise ValueError("Invalid timestamp")
416-
417-
418-
def microsecond_timestamp_conversion(time: str) -> _dt.datetime:
419-
"""Converts a microsecond timestamp and returns a datetime object of the current time in UTC"""
420-
return _dt.datetime.strptime(
421-
time,
422-
"%Y-%m-%dT%H:%M:%S.%f%z",
423-
)
373+
"""Converts an ISO 8601 timestamp and returns a timezone-aware datetime object."""
374+
normalized_time = normalize_timestamp_string(time)
375+
if "." not in normalized_time:
376+
return _dt.datetime.strptime(normalized_time, "%Y-%m-%dT%H:%M:%S%z")
377+
return _dt.datetime.strptime(normalized_time, "%Y-%m-%dT%H:%M:%S.%f%z")
424378

425379

426380
def normalize_path(path: str) -> str:

tests/test_util.py

Lines changed: 13 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,10 @@
1919
from os import environ, path
2020

2121
from firebase_functions.private.util import (
22-
PrecisionTimestamp,
2322
_unsafe_decode_id_token,
2423
deep_merge,
2524
firebase_config,
26-
get_precision_timestamp,
27-
microsecond_timestamp_conversion,
28-
nanoseconds_timestamp_conversion,
2925
normalize_path,
30-
second_timestamp_conversion,
3126
timestamp_conversion,
3227
)
3328

@@ -57,57 +52,6 @@ def test_firebase_config_loads_from_env_file():
5752
)
5853

5954

60-
def test_microsecond_conversion():
61-
"""
62-
Testing microsecond_timestamp_conversion works as intended
63-
"""
64-
timestamps = [
65-
("2023-06-20T10:15:22.396358Z", "2023-06-20T10:15:22.396358Z"),
66-
("2021-02-20T11:23:45.987123Z", "2021-02-20T11:23:45.987123Z"),
67-
("2022-09-18T09:15:38.246824Z", "2022-09-18T09:15:38.246824Z"),
68-
("2010-09-18T09:15:38.246824Z", "2010-09-18T09:15:38.246824Z"),
69-
]
70-
71-
for input_timestamp, expected_output in timestamps:
72-
expected_datetime = _dt.datetime.strptime(expected_output, "%Y-%m-%dT%H:%M:%S.%fZ")
73-
expected_datetime = expected_datetime.replace(tzinfo=_dt.timezone.utc)
74-
assert microsecond_timestamp_conversion(input_timestamp) == expected_datetime
75-
76-
77-
def test_nanosecond_conversion():
78-
"""
79-
Testing nanoseconds_timestamp_conversion works as intended
80-
"""
81-
timestamps = [
82-
("2023-01-01T12:34:56.123456789Z", "2023-01-01T12:34:56.123456Z"),
83-
("2023-02-14T14:37:52.987654321Z", "2023-02-14T14:37:52.987654Z"),
84-
("2023-03-21T06:43:58.564738291Z", "2023-03-21T06:43:58.564738Z"),
85-
("2023-08-15T22:22:22.222222222Z", "2023-08-15T22:22:22.222222Z"),
86-
]
87-
88-
for input_timestamp, expected_output in timestamps:
89-
expected_datetime = _dt.datetime.strptime(expected_output, "%Y-%m-%dT%H:%M:%S.%fZ")
90-
expected_datetime = expected_datetime.replace(tzinfo=_dt.timezone.utc)
91-
assert nanoseconds_timestamp_conversion(input_timestamp) == expected_datetime
92-
93-
94-
def test_second_conversion():
95-
"""
96-
Testing seconds_timestamp_conversion works as intended
97-
"""
98-
timestamps = [
99-
("2023-01-01T12:34:56Z", "2023-01-01T12:34:56Z"),
100-
("2023-02-14T14:37:52Z", "2023-02-14T14:37:52Z"),
101-
("2023-03-21T06:43:58Z", "2023-03-21T06:43:58Z"),
102-
("2023-10-06T07:00:00Z", "2023-10-06T07:00:00Z"),
103-
]
104-
105-
for input_timestamp, expected_output in timestamps:
106-
expected_datetime = _dt.datetime.strptime(expected_output, "%Y-%m-%dT%H:%M:%SZ")
107-
expected_datetime = expected_datetime.replace(tzinfo=_dt.timezone.utc)
108-
assert second_timestamp_conversion(input_timestamp) == expected_datetime
109-
110-
11155
def test_timestamp_conversion_supported_formats():
11256
"""
11357
Testing shared timestamp conversion handles supported RTDB and CloudEvent formats.
@@ -151,6 +95,19 @@ def test_timestamp_conversion_supported_formats():
15195
"2023-01-01T12:34:56.123456789Z",
15296
_dt.datetime(2023, 1, 1, 12, 34, 56, 123456, tzinfo=_dt.timezone.utc),
15397
),
98+
(
99+
"2023-01-01T12:34:56.123456789+05:30",
100+
_dt.datetime(
101+
2023,
102+
1,
103+
1,
104+
12,
105+
34,
106+
56,
107+
123456,
108+
tzinfo=_dt.timezone(_dt.timedelta(hours=5, minutes=30)),
109+
),
110+
),
154111
(
155112
"2025-10-30T21:15:51Z",
156113
_dt.datetime(2025, 10, 30, 21, 15, 51, tzinfo=_dt.timezone.utc),
@@ -161,39 +118,6 @@ def test_timestamp_conversion_supported_formats():
161118
assert timestamp_conversion(input_timestamp) == expected_datetime
162119

163120

164-
def test_is_nanoseconds_timestamp():
165-
"""
166-
Testing is_nanoseconds_timestamp works as intended
167-
"""
168-
microsecond_timestamp1 = "2023-06-20T10:15:22.396358Z"
169-
microsecond_timestamp2 = "2021-02-20T11:23:45.987123Z"
170-
microsecond_timestamp3 = "2022-09-18T09:15:38.246824Z"
171-
microsecond_timestamp4 = "2010-09-18T09:15:38.246824Z"
172-
173-
nanosecond_timestamp1 = "2023-01-01T12:34:56.123456789Z"
174-
nanosecond_timestamp2 = "2023-02-14T14:37:52.987654321Z"
175-
nanosecond_timestamp3 = "2023-03-21T06:43:58.564738291Z"
176-
nanosecond_timestamp4 = "2023-08-15T22:22:22.222222222Z"
177-
178-
second_timestamp1 = "2023-01-01T12:34:56Z"
179-
second_timestamp2 = "2023-02-14T14:37:52Z"
180-
second_timestamp3 = "2023-03-21T06:43:58Z"
181-
second_timestamp4 = "2023-08-15T22:22:22Z"
182-
183-
assert get_precision_timestamp(microsecond_timestamp1) is PrecisionTimestamp.MICROSECONDS
184-
assert get_precision_timestamp(microsecond_timestamp2) is PrecisionTimestamp.MICROSECONDS
185-
assert get_precision_timestamp(microsecond_timestamp3) is PrecisionTimestamp.MICROSECONDS
186-
assert get_precision_timestamp(microsecond_timestamp4) is PrecisionTimestamp.MICROSECONDS
187-
assert get_precision_timestamp(nanosecond_timestamp1) is PrecisionTimestamp.NANOSECONDS
188-
assert get_precision_timestamp(nanosecond_timestamp2) is PrecisionTimestamp.NANOSECONDS
189-
assert get_precision_timestamp(nanosecond_timestamp3) is PrecisionTimestamp.NANOSECONDS
190-
assert get_precision_timestamp(nanosecond_timestamp4) is PrecisionTimestamp.NANOSECONDS
191-
assert get_precision_timestamp(second_timestamp1) is PrecisionTimestamp.SECONDS
192-
assert get_precision_timestamp(second_timestamp2) is PrecisionTimestamp.SECONDS
193-
assert get_precision_timestamp(second_timestamp3) is PrecisionTimestamp.SECONDS
194-
assert get_precision_timestamp(second_timestamp4) is PrecisionTimestamp.SECONDS
195-
196-
197121
def test_normalize_document_path():
198122
"""
199123
Testing "document" path passed to Firestore event listener

0 commit comments

Comments
 (0)