Skip to content

Commit ddb287f

Browse files
committed
fix(util): address feedback
1 parent 04aab56 commit ddb287f

File tree

2 files changed

+152
-83
lines changed

2 files changed

+152
-83
lines changed

src/firebase_functions/private/util.py

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -402,33 +402,44 @@ def get_precision_timestamp(time: str) -> PrecisionTimestamp:
402402
return PrecisionTimestamp.MICROSECONDS
403403

404404

405-
def timestamp_conversion(time) -> _dt.datetime:
405+
def timestamp_conversion(timestamp: str | dict | _typing.Any) -> _dt.datetime:
406406
"""
407-
Converts a timestamp and returns a datetime object of the current time in UTC.
408-
Accepts RFC 3339/ISO 8601 strings or Firebase Timestamp objects (with 'seconds', 'nanoseconds' attributes).
407+
Converts a timestamp-like value to a timezone-aware UTC datetime.
408+
409+
Accepts RFC 3339/ISO 8601 strings or Firebase Timestamp objects
410+
(with 'seconds', 'nanoseconds' attributes).
409411
"""
410412
# Handle Firebase Timestamp object case
411413
# Accept dict-like objects, or python objects with 'seconds' and 'nanoseconds' attributes
412-
if hasattr(time, "seconds") and hasattr(time, "nanoseconds"):
413-
# Use UTC time
414-
return _dt.datetime.fromtimestamp(
415-
time.seconds + time.nanoseconds / 1_000_000_000, tz=_dt.timezone.utc
416-
)
417-
elif isinstance(time, dict) and "seconds" in time and "nanoseconds" in time:
418-
return _dt.datetime.fromtimestamp(
419-
time["seconds"] + time["nanoseconds"] / 1_000_000_000, tz=_dt.timezone.utc
420-
)
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)
421432

422433
# Assume string input
423-
if not isinstance(time, str):
434+
if not isinstance(timestamp, str):
424435
raise ValueError("timestamp_conversion expects a string or a Timestamp-like object")
425-
precision_timestamp = get_precision_timestamp(time)
436+
precision_timestamp = get_precision_timestamp(timestamp)
426437
if precision_timestamp == PrecisionTimestamp.NANOSECONDS:
427-
return nanoseconds_timestamp_conversion(time)
438+
return nanoseconds_timestamp_conversion(timestamp)
428439
elif precision_timestamp == PrecisionTimestamp.MICROSECONDS:
429-
return microsecond_timestamp_conversion(time)
440+
return microsecond_timestamp_conversion(timestamp)
430441
elif precision_timestamp == PrecisionTimestamp.SECONDS:
431-
return second_timestamp_conversion(time)
442+
return second_timestamp_conversion(timestamp)
432443
raise ValueError("Invalid timestamp")
433444

434445

tests/test_util.py

Lines changed: 124 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -192,87 +192,145 @@ def test_unsafe_decode_token():
192192
assert result["name"] == "John Doe"
193193

194194

195-
def test_timestamp_conversion_with_object():
196-
"""
197-
Testing timestamp_conversion works with objects that have seconds and nanoseconds attributes.
198-
"""
199-
200-
class Timestamp:
201-
def __init__(self, seconds, nanoseconds):
202-
self.seconds = seconds
203-
self.nanoseconds = nanoseconds
204-
205-
test_cases = [
206-
(1672578896, 123456789),
207-
(1672578896, 0),
208-
(1672578896, 1_500_000_000),
209-
]
210-
211-
for seconds, nanoseconds in test_cases:
212-
timestamp_obj = Timestamp(seconds=seconds, nanoseconds=nanoseconds)
213-
result = timestamp_conversion(timestamp_obj)
214-
expected = _dt.datetime.fromtimestamp(
215-
seconds + nanoseconds / 1_000_000_000, tz=_dt.timezone.utc
216-
)
217-
assert result == expected
218-
assert result.tzinfo == _dt.timezone.utc
219-
220-
221-
def test_timestamp_conversion_with_dict():
222-
"""
223-
Testing timestamp_conversion works with dict objects containing seconds and nanoseconds keys.
224-
"""
225-
test_cases = [
226-
(1687256122, 396358000),
195+
# Helper class for timestamp conversion tests
196+
class _Timestamp:
197+
"""Helper class to simulate Firebase Timestamp objects."""
198+
199+
def __init__(self, seconds: int, nanoseconds: int):
200+
self.seconds = seconds
201+
self.nanoseconds = nanoseconds
202+
203+
204+
def _assert_utc_datetime(dt: _dt.datetime) -> None:
205+
"""Helper to assert datetime is UTC timezone-aware."""
206+
assert dt.tzinfo == _dt.timezone.utc
207+
208+
209+
@pytest.mark.parametrize(
210+
"seconds,nanoseconds,expected_str",
211+
[
212+
(0, 0, "1970-01-01T00:00:00.000000+00:00"), # The epoch
213+
(1, 0, "1970-01-01T00:00:01.000000+00:00"), # 1 second after epoch
214+
(0, 1, "1970-01-01T00:00:00.000000+00:00"), # 1 nanosecond (truncated)
215+
(0, 999_999, "1970-01-01T00:00:00.000999+00:00"), # < 1 microsecond
216+
(0, 1_000, "1970-01-01T00:00:00.000001+00:00"), # 1 microsecond
217+
(0, 999_999_999, "1970-01-01T00:00:00.999999+00:00"), # almost 1 second
218+
(0, 1_000_000_000, "1970-01-01T00:00:01.000000+00:00"), # exactly 1 second (carries)
219+
(123456, 1_500_000_000, "1970-01-02T10:17:37.500000+00:00"), # overflow with remainder
220+
(1672578896, 123456789, "2023-01-01T13:14:56.123456+00:00"), # real-world example
221+
(-1, 0, "1969-12-31T23:59:59.000000+00:00"), # 1 second before epoch
222+
(-1, 500_000_000, "1969-12-31T23:59:59.500000+00:00"), # negative seconds, positive nsec
223+
],
224+
)
225+
def test_timestamp_conversion_object_known_cases(seconds: int, nanoseconds: int, expected_str: str):
226+
"""Test timestamp_conversion with objects using known correct expected values."""
227+
timestamp_obj = _Timestamp(seconds=seconds, nanoseconds=nanoseconds)
228+
result = timestamp_conversion(timestamp_obj)
229+
expected = _dt.datetime.fromisoformat(expected_str)
230+
assert result == expected
231+
_assert_utc_datetime(result)
232+
233+
234+
@pytest.mark.parametrize(
235+
"seconds,nanoseconds",
236+
[
237+
(123456, -500_000_000), # negative nanoseconds
238+
(123456, 2_999_999_999), # large nanoseconds, multiple second carry
239+
(2_147_483_647, 0), # max 32-bit int
240+
(-2, 2_000_000_000), # negative seconds, nanoseconds w/ carry
241+
(0, -1), # negative nanoseconds underflow
242+
(0, -1_000_000_000), # underflow full second
243+
(0, -1_500_000_000), # underflow more than one second
244+
(1687256122, 396358000), # nominal case
227245
(1687256122, 0),
228-
]
246+
(0, 0),
247+
(0, 1),
248+
(0, 999_999_999),
249+
(1687256122, 2_000_000_000),
250+
(1687256122, -500_000_000),
251+
(-1, 999_999_999),
252+
(-1, 500_000_000),
253+
(-2, 2_000_000_000),
254+
(2_147_483_647, 999_999_999),
255+
(-2_147_483_648, 0),
256+
(0, -2_000_000_000),
257+
(0, 2_000_000_000),
258+
],
259+
)
260+
def test_timestamp_conversion_object_dict_consistency(seconds: int, nanoseconds: int):
261+
"""Test that object and dict branches produce identical results."""
262+
timestamp_obj = _Timestamp(seconds=seconds, nanoseconds=nanoseconds)
263+
timestamp_dict = {"seconds": seconds, "nanoseconds": nanoseconds}
264+
265+
result_obj = timestamp_conversion(timestamp_obj)
266+
result_dict = timestamp_conversion(timestamp_dict)
267+
268+
assert result_obj == result_dict
269+
_assert_utc_datetime(result_obj)
270+
_assert_utc_datetime(result_dict)
271+
272+
273+
@pytest.mark.parametrize(
274+
"seconds,nanoseconds",
275+
[
276+
(1672576496, 123456000), # nanoseconds already in microsecond precision
277+
(1672576496, 0),
278+
(1672576496, 999999000),
279+
],
280+
)
281+
def test_timestamp_conversion_string_cross_validation(seconds: int, nanoseconds: int):
282+
"""Test cross-validation with string path for microsecond-precision nanoseconds."""
283+
dt_from_obj = timestamp_conversion(_Timestamp(seconds=seconds, nanoseconds=nanoseconds))
284+
iso_str = dt_from_obj.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
285+
dt_from_string = timestamp_conversion(iso_str)
229286

230-
for seconds, nanoseconds in test_cases:
231-
timestamp_dict = {"seconds": seconds, "nanoseconds": nanoseconds}
232-
result = timestamp_conversion(timestamp_dict)
233-
expected = _dt.datetime.fromtimestamp(
234-
seconds + nanoseconds / 1_000_000_000, tz=_dt.timezone.utc
235-
)
236-
assert result == expected
237-
assert result.tzinfo == _dt.timezone.utc
287+
assert dt_from_obj == dt_from_string
238288

239289

240-
def test_timestamp_conversion_with_string():
241-
"""
242-
Testing timestamp_conversion works with string inputs.
243-
"""
244-
test_cases = [
290+
@pytest.mark.parametrize(
291+
"timestamp_str,conversion_func",
292+
[
245293
("2023-01-01T12:34:56.123456789Z", nanoseconds_timestamp_conversion),
246294
("2023-06-20T10:15:22.396358Z", microsecond_timestamp_conversion),
247295
("2023-01-01T12:34:56Z", second_timestamp_conversion),
248-
]
249-
250-
for timestamp_str, conversion_func in test_cases:
251-
result = timestamp_conversion(timestamp_str)
252-
expected = conversion_func(timestamp_str)
253-
assert result == expected
296+
],
297+
)
298+
def test_timestamp_conversion_with_string(timestamp_str: str, conversion_func):
299+
"""Test timestamp_conversion works with string inputs."""
300+
result = timestamp_conversion(timestamp_str)
301+
expected = conversion_func(timestamp_str)
302+
assert result == expected
303+
_assert_utc_datetime(result)
304+
305+
306+
@pytest.mark.parametrize(
307+
"invalid_input,expected_error_msg",
308+
[
309+
(12345, "timestamp_conversion expects a string or a Timestamp-like object"),
310+
("invalid_timestamp", None), # Error message varies, just check ValueError
311+
(None, None),
312+
],
313+
)
314+
def test_timestamp_conversion_errors(invalid_input, expected_error_msg):
315+
"""Test timestamp_conversion raises appropriate errors for invalid inputs."""
316+
with pytest.raises(ValueError) as exc_info:
317+
timestamp_conversion(invalid_input)
318+
if expected_error_msg:
319+
assert expected_error_msg in str(exc_info.value)
254320

255321

256-
def test_timestamp_conversion_errors():
257-
"""
258-
Testing timestamp_conversion raises appropriate errors for invalid inputs.
259-
"""
322+
def test_timestamp_conversion_error_missing_seconds():
323+
"""Test timestamp_conversion raises error when seconds attribute is missing."""
260324

261325
class IncompleteTimestamp:
262-
def __init__(self, nanoseconds):
326+
def __init__(self, nanoseconds: int):
263327
self.nanoseconds = nanoseconds
264328

265329
with pytest.raises(ValueError):
266330
timestamp_conversion(IncompleteTimestamp(nanoseconds=123456789))
267331

268-
with pytest.raises(ValueError) as context:
269-
timestamp_conversion(12345)
270-
assert "timestamp_conversion expects a string or a Timestamp-like object" in str(context.value)
271-
with pytest.raises(ValueError):
272-
timestamp_conversion({"nanoseconds": 123456789})
273332

333+
def test_timestamp_conversion_error_missing_nanoseconds():
334+
"""Test timestamp_conversion raises error when nanoseconds key is missing in dict."""
274335
with pytest.raises(ValueError):
275-
timestamp_conversion("invalid_timestamp")
276-
277-
with pytest.raises(ValueError):
278-
timestamp_conversion(None)
336+
timestamp_conversion({"nanoseconds": 123456789})

0 commit comments

Comments
 (0)