Skip to content

Commit 9451c72

Browse files
committed
fix: util timestamp nanos and no timezone suffix
1 parent ddb287f commit 9451c72

File tree

2 files changed

+66
-22
lines changed

2 files changed

+66
-22
lines changed

src/firebase_functions/private/util.py

Lines changed: 31 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -392,8 +392,12 @@ def get_precision_timestamp(time: str) -> PrecisionTimestamp:
392392
except ValueError:
393393
return PrecisionTimestamp.SECONDS
394394

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")
395+
# Split the fraction from the timezone specifier ('Z' or 'z') when present
396+
if "Z" in s_fraction:
397+
s_fraction, _ = s_fraction.split("Z", 1)
398+
elif "z" in s_fraction:
399+
s_fraction, _ = s_fraction.split("z", 1)
400+
# else: no timezone suffix (e.g. "000"), use fraction as-is
397401

398402
# If the fraction is more than 6 digits long, it's a nanosecond timestamp
399403
if len(s_fraction) > 6:
@@ -407,28 +411,33 @@ def timestamp_conversion(timestamp: str | dict | _typing.Any) -> _dt.datetime:
407411
Converts a timestamp-like value to a timezone-aware UTC datetime.
408412
409413
Accepts RFC 3339/ISO 8601 strings or Firebase Timestamp objects
410-
(with 'seconds', 'nanoseconds' attributes).
414+
(with 'seconds' and 'nanoseconds' or 'nanos' attributes).
411415
"""
412416
# Handle Firebase Timestamp object case
413-
# Accept dict-like objects, or python objects with 'seconds' and 'nanoseconds' attributes
414-
if hasattr(timestamp, "seconds") and hasattr(timestamp, "nanoseconds"):
415-
# Normalize nanoseconds into seconds (handles values >= 1_000_000_000 or < 0)
416-
carry, ns = divmod(int(timestamp.nanoseconds), 1_000_000_000)
417-
secs = int(timestamp.seconds) + carry
418-
# Truncate (deterministic, no floating precision issues, matches string path behavior)
419-
microseconds = ns // 1_000
420-
# Build without using fromtimestamp
421-
epoch = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc)
422-
return epoch + _dt.timedelta(seconds=secs, microseconds=microseconds)
423-
elif isinstance(timestamp, dict) and "seconds" in timestamp and "nanoseconds" in timestamp:
424-
# Normalize nanoseconds into seconds (handles values >= 1_000_000_000 or < 0)
425-
carry, ns = divmod(int(timestamp["nanoseconds"]), 1_000_000_000)
426-
secs = int(timestamp["seconds"]) + carry
427-
# Truncate (deterministic, no floating precision issues, matches string path behavior)
428-
microseconds = ns // 1_000
429-
# Build without using fromtimestamp
430-
epoch = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc)
431-
return epoch + _dt.timedelta(seconds=secs, microseconds=microseconds)
417+
# Accept objects with .seconds and .nanoseconds or .nanos
418+
if hasattr(timestamp, "seconds"):
419+
ns_val = getattr(timestamp, "nanoseconds", getattr(timestamp, "nanos", None))
420+
if ns_val is not None:
421+
# Normalize nanoseconds into seconds (handles values >= 1_000_000_000 or < 0)
422+
carry, ns = divmod(int(ns_val), 1_000_000_000)
423+
secs = int(timestamp.seconds) + carry
424+
# Truncate (deterministic, no floating precision issues, matches string path behavior)
425+
microseconds = ns // 1_000
426+
# Build without using fromtimestamp
427+
epoch = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc)
428+
return epoch + _dt.timedelta(seconds=secs, microseconds=microseconds)
429+
# Handle dict (e.g. protobuf-style with "nanos" or "nanoseconds")
430+
elif isinstance(timestamp, dict) and "seconds" in timestamp:
431+
nanos_val = timestamp.get("nanoseconds", timestamp.get("nanos"))
432+
if nanos_val is not None:
433+
# Normalize nanoseconds into seconds (handles values >= 1_000_000_000 or < 0)
434+
carry, ns = divmod(int(nanos_val), 1_000_000_000)
435+
secs = int(timestamp["seconds"]) + carry
436+
# Truncate (deterministic, no floating precision issues, matches string path behavior)
437+
microseconds = ns // 1_000
438+
# Build without using fromtimestamp
439+
epoch = _dt.datetime(1970, 1, 1, tzinfo=_dt.timezone.utc)
440+
return epoch + _dt.timedelta(seconds=secs, microseconds=microseconds)
432441

433442
# Assume string input
434443
if not isinstance(timestamp, str):

tests/test_util.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,41 @@ def test_timestamp_conversion_object_dict_consistency(seconds: int, nanoseconds:
270270
_assert_utc_datetime(result_dict)
271271

272272

273+
def test_timestamp_conversion_dict_nanos_equivalent_to_nanoseconds():
274+
"""Test that dict with 'nanos' (protobuf-style) returns same result as 'nanoseconds'."""
275+
dict_nanos = {"seconds": "1730890800", "nanos": 0}
276+
dict_nanoseconds = {"seconds": "1730890800", "nanoseconds": 0}
277+
result_nanos = timestamp_conversion(dict_nanos)
278+
result_nanoseconds = timestamp_conversion(dict_nanoseconds)
279+
assert result_nanos == result_nanoseconds
280+
_assert_utc_datetime(result_nanos)
281+
assert result_nanos == _dt.datetime(2024, 11, 6, 11, 0, 0, tzinfo=_dt.timezone.utc)
282+
283+
284+
def test_timestamp_conversion_object_nanos():
285+
"""Test that object with .nanos (no .nanoseconds) is accepted and matches dict result."""
286+
287+
class TimestampWithNanos:
288+
def __init__(self, seconds: int, nanos: int):
289+
self.seconds = seconds
290+
self.nanos = nanos
291+
292+
obj = TimestampWithNanos(seconds=1730890800, nanos=0)
293+
d = {"seconds": 1730890800, "nanos": 0}
294+
result_obj = timestamp_conversion(obj)
295+
result_dict = timestamp_conversion(d)
296+
assert result_obj == result_dict
297+
_assert_utc_datetime(result_obj)
298+
299+
300+
def test_get_precision_timestamp_without_timezone_suffix():
301+
"""Test get_precision_timestamp does not crash when fraction has no Z/z (e.g. .000)."""
302+
# String without timezone suffix - previously raised ValueError on unpack
303+
ts = "2025-11-06T12:00:00.000"
304+
result = get_precision_timestamp(ts)
305+
assert result is PrecisionTimestamp.MICROSECONDS
306+
307+
273308
@pytest.mark.parametrize(
274309
"seconds,nanoseconds",
275310
[

0 commit comments

Comments
 (0)