Skip to content

Commit a4eb8fa

Browse files
authored
fix: timestamp parsing for RTDB events (#273)
1 parent f0cb25f commit a4eb8fa

4 files changed

Lines changed: 117 additions & 155 deletions

File tree

src/firebase_functions/db_fn.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717

1818
# pylint: disable=protected-access
1919
import dataclasses as _dataclass
20-
import datetime as _dt
2120
import functools as _functools
2221
import typing as _typing
2322

@@ -126,10 +125,7 @@ def _db_endpoint_handler(
126125
id=event_attributes["id"],
127126
source=event_attributes["source"],
128127
type=event_attributes["type"],
129-
time=_dt.datetime.strptime(
130-
event_attributes["time"],
131-
"%Y-%m-%dT%H:%M:%S.%f%z",
132-
),
128+
time=_util.timestamp_conversion(event_attributes["time"]),
133129
data=database_event_data,
134130
subject=event_attributes["subject"],
135131
params=params,

src/firebase_functions/private/util.py

Lines changed: 21 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -348,80 +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)
357+
# Whole-second timestamps already fit the standard parser.
358+
if "." not in time:
359+
return time
360360

361-
return event_time
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)):
365+
raise ValueError(f"Invalid timestamp format: {time}")
362366

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-
The status of a token.
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 a bool which indicates if the timestamp is in nanoseconds"""
389-
# Split the string into date-time and fraction of second
390-
try:
391-
_, s_fraction = time.split(".")
392-
except ValueError:
393-
return PrecisionTimestamp.SECONDS
394-
395-
# Split the fraction from the timezone specifier ('Z' or 'z')
396-
s_fraction, _ = s_fraction.split("Z") if "Z" in s_fraction else s_fraction.split("z")
397-
398-
# If the fraction is more than 6 digits long, it's a nanosecond timestamp
399-
if len(s_fraction) > 6:
400-
return PrecisionTimestamp.NANOSECONDS
401-
else:
402-
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}"
403370

404371

405372
def timestamp_conversion(time: str) -> _dt.datetime:
406-
"""Converts a timestamp and returns a datetime object of the current time in UTC"""
407-
precision_timestamp = get_precision_timestamp(time)
408-
409-
if precision_timestamp == PrecisionTimestamp.NANOSECONDS:
410-
return nanoseconds_timestamp_conversion(time)
411-
elif precision_timestamp == PrecisionTimestamp.MICROSECONDS:
412-
return microsecond_timestamp_conversion(time)
413-
elif precision_timestamp == PrecisionTimestamp.SECONDS:
414-
return second_timestamp_conversion(time)
415-
416-
raise ValueError("Invalid timestamp")
417-
418-
419-
def microsecond_timestamp_conversion(time: str) -> _dt.datetime:
420-
"""Converts a microsecond timestamp and returns a datetime object of the current time in UTC"""
421-
return _dt.datetime.strptime(
422-
time,
423-
"%Y-%m-%dT%H:%M:%S.%f%z",
424-
)
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")
425378

426379

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

tests/test_db.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
Tests for the db module.
33
"""
44

5+
import datetime as dt
56
import unittest
67
from unittest import mock
78

@@ -81,3 +82,37 @@ def test_missing_auth_context(self):
8182
self.assertIsNotNone(event_arg)
8283
self.assertEqual(event_arg.auth_type, "unknown")
8384
self.assertIsNone(event_arg.auth_id)
85+
86+
def test_written_event_parses_timestamp_without_microseconds(self):
87+
func = mock.Mock(__name__="example_func_no_microseconds")
88+
decorated_func = db_fn.on_value_written(reference="/items/{itemId}")(func)
89+
90+
event = CloudEvent(
91+
attributes={
92+
"specversion": "1.0",
93+
"id": "issue-257-repro",
94+
"source": "//firebase.test/projects/demo-test/instances/my-instance/refs/items/123",
95+
"subject": "refs/items/123",
96+
"type": "google.firebase.database.ref.v1.written",
97+
"time": "2025-10-30T21:15:51Z",
98+
"instance": "my-instance",
99+
"ref": "/items/123",
100+
"firebasedatabasehost": "my-instance.firebaseio.com",
101+
"location": "location",
102+
},
103+
data={
104+
"data": {"existing": True},
105+
"delta": {"updated": True},
106+
},
107+
)
108+
109+
decorated_func(event)
110+
111+
func.assert_called_once()
112+
event_arg = func.call_args.args[0]
113+
self.assertEqual(
114+
event_arg.time,
115+
dt.datetime(2025, 10, 30, 21, 15, 51, tzinfo=dt.timezone.utc),
116+
)
117+
self.assertEqual(event_arg.data.after, {"existing": True, "updated": True})
118+
self.assertEqual(event_arg.params, {"itemId": "123"})

tests/test_util.py

Lines changed: 60 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,11 @@
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,
26+
timestamp_conversion,
3127
)
3228

3329
test_bucket = "python-functions-testing.appspot.com"
@@ -56,88 +52,70 @@ def test_firebase_config_loads_from_env_file():
5652
)
5753

5854

59-
def test_microsecond_conversion():
55+
def test_timestamp_conversion_supported_formats():
6056
"""
61-
Testing microsecond_timestamp_conversion works as intended
57+
Testing shared timestamp conversion handles supported RTDB and CloudEvent formats.
6258
"""
6359
timestamps = [
64-
("2023-06-20T10:15:22.396358Z", "2023-06-20T10:15:22.396358Z"),
65-
("2021-02-20T11:23:45.987123Z", "2021-02-20T11:23:45.987123Z"),
66-
("2022-09-18T09:15:38.246824Z", "2022-09-18T09:15:38.246824Z"),
67-
("2010-09-18T09:15:38.246824Z", "2010-09-18T09:15:38.246824Z"),
60+
(
61+
"2024-04-10T12:00:00.000Z",
62+
_dt.datetime(2024, 4, 10, 12, 0, tzinfo=_dt.timezone.utc),
63+
),
64+
(
65+
"2024-04-10T12:00:00.123456Z",
66+
_dt.datetime(2024, 4, 10, 12, 0, 0, 123456, tzinfo=_dt.timezone.utc),
67+
),
68+
(
69+
"2024-04-10T12:00:00.123456+05:30",
70+
_dt.datetime(
71+
2024,
72+
4,
73+
10,
74+
12,
75+
0,
76+
0,
77+
123456,
78+
tzinfo=_dt.timezone(_dt.timedelta(hours=5, minutes=30)),
79+
),
80+
),
81+
(
82+
"2024-04-10T12:00:00.123456-0700",
83+
_dt.datetime(
84+
2024,
85+
4,
86+
10,
87+
12,
88+
0,
89+
0,
90+
123456,
91+
tzinfo=_dt.timezone(-_dt.timedelta(hours=7)),
92+
),
93+
),
94+
(
95+
"2023-01-01T12:34:56.123456789Z",
96+
_dt.datetime(2023, 1, 1, 12, 34, 56, 123456, tzinfo=_dt.timezone.utc),
97+
),
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+
),
111+
(
112+
"2025-10-30T21:15:51Z",
113+
_dt.datetime(2025, 10, 30, 21, 15, 51, tzinfo=_dt.timezone.utc),
114+
),
68115
]
69116

70-
for input_timestamp, expected_output in timestamps:
71-
expected_datetime = _dt.datetime.strptime(expected_output, "%Y-%m-%dT%H:%M:%S.%fZ")
72-
expected_datetime = expected_datetime.replace(tzinfo=_dt.timezone.utc)
73-
assert microsecond_timestamp_conversion(input_timestamp) == expected_datetime
74-
75-
76-
def test_nanosecond_conversion():
77-
"""
78-
Testing nanoseconds_timestamp_conversion works as intended
79-
"""
80-
timestamps = [
81-
("2023-01-01T12:34:56.123456789Z", "2023-01-01T12:34:56.123456Z"),
82-
("2023-02-14T14:37:52.987654321Z", "2023-02-14T14:37:52.987654Z"),
83-
("2023-03-21T06:43:58.564738291Z", "2023-03-21T06:43:58.564738Z"),
84-
("2023-08-15T22:22:22.222222222Z", "2023-08-15T22:22:22.222222Z"),
85-
]
86-
87-
for input_timestamp, expected_output in timestamps:
88-
expected_datetime = _dt.datetime.strptime(expected_output, "%Y-%m-%dT%H:%M:%S.%fZ")
89-
expected_datetime = expected_datetime.replace(tzinfo=_dt.timezone.utc)
90-
assert nanoseconds_timestamp_conversion(input_timestamp) == expected_datetime
91-
92-
93-
def test_second_conversion():
94-
"""
95-
Testing seconds_timestamp_conversion works as intended
96-
"""
97-
timestamps = [
98-
("2023-01-01T12:34:56Z", "2023-01-01T12:34:56Z"),
99-
("2023-02-14T14:37:52Z", "2023-02-14T14:37:52Z"),
100-
("2023-03-21T06:43:58Z", "2023-03-21T06:43:58Z"),
101-
("2023-10-06T07:00:00Z", "2023-10-06T07:00:00Z"),
102-
]
103-
104-
for input_timestamp, expected_output in timestamps:
105-
expected_datetime = _dt.datetime.strptime(expected_output, "%Y-%m-%dT%H:%M:%SZ")
106-
expected_datetime = expected_datetime.replace(tzinfo=_dt.timezone.utc)
107-
assert second_timestamp_conversion(input_timestamp) == expected_datetime
108-
109-
110-
def test_is_nanoseconds_timestamp():
111-
"""
112-
Testing is_nanoseconds_timestamp works as intended
113-
"""
114-
microsecond_timestamp1 = "2023-06-20T10:15:22.396358Z"
115-
microsecond_timestamp2 = "2021-02-20T11:23:45.987123Z"
116-
microsecond_timestamp3 = "2022-09-18T09:15:38.246824Z"
117-
microsecond_timestamp4 = "2010-09-18T09:15:38.246824Z"
118-
119-
nanosecond_timestamp1 = "2023-01-01T12:34:56.123456789Z"
120-
nanosecond_timestamp2 = "2023-02-14T14:37:52.987654321Z"
121-
nanosecond_timestamp3 = "2023-03-21T06:43:58.564738291Z"
122-
nanosecond_timestamp4 = "2023-08-15T22:22:22.222222222Z"
123-
124-
second_timestamp1 = "2023-01-01T12:34:56Z"
125-
second_timestamp2 = "2023-02-14T14:37:52Z"
126-
second_timestamp3 = "2023-03-21T06:43:58Z"
127-
second_timestamp4 = "2023-08-15T22:22:22Z"
128-
129-
assert get_precision_timestamp(microsecond_timestamp1) is PrecisionTimestamp.MICROSECONDS
130-
assert get_precision_timestamp(microsecond_timestamp2) is PrecisionTimestamp.MICROSECONDS
131-
assert get_precision_timestamp(microsecond_timestamp3) is PrecisionTimestamp.MICROSECONDS
132-
assert get_precision_timestamp(microsecond_timestamp4) is PrecisionTimestamp.MICROSECONDS
133-
assert get_precision_timestamp(nanosecond_timestamp1) is PrecisionTimestamp.NANOSECONDS
134-
assert get_precision_timestamp(nanosecond_timestamp2) is PrecisionTimestamp.NANOSECONDS
135-
assert get_precision_timestamp(nanosecond_timestamp3) is PrecisionTimestamp.NANOSECONDS
136-
assert get_precision_timestamp(nanosecond_timestamp4) is PrecisionTimestamp.NANOSECONDS
137-
assert get_precision_timestamp(second_timestamp1) is PrecisionTimestamp.SECONDS
138-
assert get_precision_timestamp(second_timestamp2) is PrecisionTimestamp.SECONDS
139-
assert get_precision_timestamp(second_timestamp3) is PrecisionTimestamp.SECONDS
140-
assert get_precision_timestamp(second_timestamp4) is PrecisionTimestamp.SECONDS
117+
for input_timestamp, expected_datetime in timestamps:
118+
assert timestamp_conversion(input_timestamp) == expected_datetime
141119

142120

143121
def test_normalize_document_path():

0 commit comments

Comments
 (0)