Skip to content

Commit da99b76

Browse files
fix: add robust date parsing in schedule_fn (#256)
Fixes #255
1 parent c5cb62a commit da99b76

File tree

5 files changed

+157
-36
lines changed

5 files changed

+157
-36
lines changed

src/firebase_functions/dataconnect_fn.py

Lines changed: 12 additions & 16 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

@@ -209,7 +208,9 @@ def _dataconnect_endpoint_handler(
209208
) -> None:
210209
# Currently, only mutationExecuted is supported
211210
if event_type != _event_type_mutation_executed:
212-
raise NotImplementedError(f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported.")
211+
raise NotImplementedError(
212+
f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported."
213+
)
213214

214215
event_attributes = raw._get_attributes()
215216
event_data: _typing.Any = raw.get_data()
@@ -224,15 +225,9 @@ def _dataconnect_endpoint_handler(
224225
if service_pattern:
225226
params = {**params, **service_pattern.extract_matches(event_service)}
226227
if connector_pattern:
227-
params = {
228-
**params,
229-
**connector_pattern.extract_matches(event_connector)
230-
}
228+
params = {**params, **connector_pattern.extract_matches(event_connector)}
231229
if operation_pattern:
232-
params = {
233-
**params,
234-
**operation_pattern.extract_matches(event_operation)
235-
}
230+
params = {**params, **operation_pattern.extract_matches(event_operation)}
236231

237232
event_auth_type = event_attributes["authtype"]
238233
event_auth_id = event_attributes["authid"]
@@ -282,12 +277,13 @@ def mutation_executed_handler(event: Event[MutationEventData]):
282277
options = DataConnectOptions(**kwargs)
283278

284279
def on_mutation_executed_inner_decorator(func: _C1):
285-
service_pattern = _path_pattern.PathPattern(
286-
options.service) if options.service else None
287-
connector_pattern = _path_pattern.PathPattern(
288-
options.connector) if options.connector else None
289-
operation_pattern = _path_pattern.PathPattern(
290-
options.operation) if options.operation else None
280+
service_pattern = _path_pattern.PathPattern(options.service) if options.service else None
281+
connector_pattern = (
282+
_path_pattern.PathPattern(options.connector) if options.connector else None
283+
)
284+
operation_pattern = (
285+
_path_pattern.PathPattern(options.operation) if options.operation else None
286+
)
291287

292288
@_functools.wraps(func)
293289
def on_mutation_executed_wrapped(raw: _ce.CloudEvent):

src/firebase_functions/options.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1188,22 +1188,22 @@ def _endpoint(
11881188
event_filters_path_patterns: _typing.Any = {}
11891189

11901190
if self.service:
1191-
if service_pattern.has_wildcards:
1192-
event_filters_path_patterns["service"] = service_pattern.value
1193-
else:
1194-
event_filters["service"] = service_pattern.value
1191+
if service_pattern.has_wildcards:
1192+
event_filters_path_patterns["service"] = service_pattern.value
1193+
else:
1194+
event_filters["service"] = service_pattern.value
11951195

11961196
if self.connector:
1197-
if connector_pattern.has_wildcards:
1198-
event_filters_path_patterns["connector"] = connector_pattern.value
1199-
else:
1200-
event_filters["connector"] = connector_pattern.value
1197+
if connector_pattern.has_wildcards:
1198+
event_filters_path_patterns["connector"] = connector_pattern.value
1199+
else:
1200+
event_filters["connector"] = connector_pattern.value
12011201

12021202
if self.operation:
1203-
if operation_pattern.has_wildcards:
1204-
event_filters_path_patterns["operation"] = operation_pattern.value
1205-
else:
1206-
event_filters["operation"] = operation_pattern.value
1203+
if operation_pattern.has_wildcards:
1204+
event_filters_path_patterns["operation"] = operation_pattern.value
1205+
else:
1206+
event_filters["operation"] = operation_pattern.value
12071207

12081208
event_trigger = _manifest.EventTrigger(
12091209
eventType=kwargs["event_type"],

src/firebase_functions/scheduler_fn.py

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import datetime as _dt
1818
import functools as _functools
1919
import typing as _typing
20+
from datetime import timezone as _timezone
2021

2122
from flask import (
2223
Request as _Request,
@@ -101,12 +102,27 @@ def on_schedule_wrapped(request: _Request) -> _Response:
101102
schedule_time: _dt.datetime
102103
schedule_time_str = request.headers.get("X-CloudScheduler-ScheduleTime")
103104
if schedule_time_str is None:
104-
schedule_time = _dt.datetime.utcnow()
105+
schedule_time = _dt.datetime.now(_timezone.utc)
105106
else:
106-
schedule_time = _dt.datetime.strptime(
107-
schedule_time_str,
108-
"%Y-%m-%dT%H:%M:%S%z",
109-
)
107+
try:
108+
# Try to parse with the stdlib which supports fractional
109+
# seconds and offsets in Python 3.11+ via fromisoformat.
110+
# Normalize RFC3339 'Z' to '+00:00' for fromisoformat.
111+
iso_str = schedule_time_str
112+
if iso_str.endswith("Z"):
113+
iso_str = iso_str[:-1] + "+00:00"
114+
schedule_time = _dt.datetime.fromisoformat(iso_str)
115+
except ValueError:
116+
# Fallback to strict parsing without fractional seconds
117+
try:
118+
schedule_time = _dt.datetime.strptime(
119+
schedule_time_str,
120+
"%Y-%m-%dT%H:%M:%S%z",
121+
)
122+
except ValueError as e:
123+
# If all parsing fails, log and use current UTC time
124+
_logging.exception(e)
125+
schedule_time = _dt.datetime.now(_timezone.utc)
110126
event = ScheduledEvent(
111127
job_name=request.headers.get("X-CloudScheduler-JobName"),
112128
schedule_time=schedule_time,

tests/test_dataconnect_fn.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ def init():
132132
"connector": "connector-id",
133133
"operation": "mutation-name",
134134
"authtype": "app_user",
135-
"authid": "auth-id"
135+
"authid": "auth-id",
136136
},
137137
data=json.dumps({}),
138138
)

tests/test_scheduler_fn.py

Lines changed: 111 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
"""Scheduler function tests."""
1515

1616
import unittest
17-
from datetime import datetime
18-
from unittest.mock import Mock
17+
from datetime import datetime, timezone
18+
from unittest.mock import Mock, patch
1919

2020
from flask import Flask, Request
2121
from werkzeug.test import EnvironBuilder
@@ -81,6 +81,30 @@ def test_on_schedule_call(self):
8181
)
8282
)
8383

84+
def test_on_schedule_call_with_z_suffix(self):
85+
"""
86+
Tests to ensure that timestamps with 'Z' suffix are parsed correctly as UTC.
87+
"""
88+
with Flask(__name__).test_request_context("/"):
89+
environ = EnvironBuilder(
90+
headers={
91+
"X-CloudScheduler-JobName": "example-job",
92+
"X-CloudScheduler-ScheduleTime": "2023-04-13T19:00:00Z",
93+
}
94+
).get_environ()
95+
mock_request = Request(environ)
96+
example_func = Mock(__name__="example_func")
97+
decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func)
98+
response = decorated_func(mock_request)
99+
100+
self.assertEqual(response.status_code, 200)
101+
example_func.assert_called_once_with(
102+
scheduler_fn.ScheduledEvent(
103+
job_name="example-job",
104+
schedule_time=datetime(2023, 4, 13, 19, 0, 0, tzinfo=timezone.utc),
105+
)
106+
)
107+
84108
def test_on_schedule_call_with_no_headers(self):
85109
"""
86110
Tests to ensure that if the function is called manually
@@ -99,6 +123,7 @@ def test_on_schedule_call_with_no_headers(self):
99123
self.assertEqual(example_func.call_count, 1)
100124
self.assertIsNone(example_func.call_args[0][0].job_name)
101125
self.assertIsNotNone(example_func.call_args[0][0].schedule_time)
126+
self.assertIsNotNone(example_func.call_args[0][0].schedule_time.tzinfo)
102127

103128
def test_on_schedule_call_with_exception(self):
104129
"""
@@ -121,6 +146,90 @@ def test_on_schedule_call_with_exception(self):
121146
self.assertEqual(response.status_code, 500)
122147
self.assertEqual(response.data, b"Test exception")
123148

149+
def test_on_schedule_call_with_fractional_seconds(self):
150+
"""
151+
Tests to ensure that timestamps with fractional seconds are parsed correctly.
152+
"""
153+
with Flask(__name__).test_request_context("/"):
154+
environ = EnvironBuilder(
155+
headers={
156+
"X-CloudScheduler-JobName": "example-job",
157+
"X-CloudScheduler-ScheduleTime": "2023-04-13T19:00:00.123456Z",
158+
}
159+
).get_environ()
160+
mock_request = Request(environ)
161+
example_func = Mock(__name__="example_func")
162+
decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func)
163+
response = decorated_func(mock_request)
164+
165+
self.assertEqual(response.status_code, 200)
166+
example_func.assert_called_once_with(
167+
scheduler_fn.ScheduledEvent(
168+
job_name="example-job",
169+
schedule_time=datetime(2023, 4, 13, 19, 0, 0, 123456, tzinfo=timezone.utc),
170+
)
171+
)
172+
173+
def test_on_schedule_call_fallback_parsing(self):
174+
"""
175+
Tests fallback parsing for formats that might fail fromisoformat
176+
but pass strptime (e.g., offset without colon).
177+
"""
178+
with Flask(__name__).test_request_context("/"):
179+
environ = EnvironBuilder(
180+
headers={
181+
"X-CloudScheduler-JobName": "example-job",
182+
# Offset without colon might fail fromisoformat in some versions
183+
# but should pass strptime("%Y-%m-%dT%H:%M:%S%z")
184+
"X-CloudScheduler-ScheduleTime": "2023-04-13T12:00:00-0700",
185+
}
186+
).get_environ()
187+
mock_request = Request(environ)
188+
example_func = Mock(__name__="example_func")
189+
decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func)
190+
response = decorated_func(mock_request)
191+
192+
self.assertEqual(response.status_code, 200)
193+
194+
# Create expected datetime with fixed offset -07:00
195+
tz = datetime.strptime("-0700", "%z").tzinfo
196+
expected_dt = datetime(2023, 4, 13, 12, 0, 0, tzinfo=tz)
197+
198+
example_func.assert_called_once_with(
199+
scheduler_fn.ScheduledEvent(
200+
job_name="example-job",
201+
schedule_time=expected_dt,
202+
)
203+
)
204+
205+
def test_on_schedule_call_invalid_timestamp(self):
206+
"""
207+
Tests that invalid timestamps log an error and fallback to current time.
208+
"""
209+
with Flask(__name__).test_request_context("/"):
210+
environ = EnvironBuilder(
211+
headers={
212+
"X-CloudScheduler-JobName": "example-job",
213+
"X-CloudScheduler-ScheduleTime": "invalid-timestamp",
214+
}
215+
).get_environ()
216+
mock_request = Request(environ)
217+
example_func = Mock(__name__="example_func")
218+
219+
with patch("firebase_functions.scheduler_fn._logging") as mock_logging:
220+
decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func)
221+
response = decorated_func(mock_request)
222+
223+
self.assertEqual(response.status_code, 200)
224+
mock_logging.exception.assert_called_once()
225+
226+
# Should have called with *some* time (current time), so we just check it's not None
227+
self.assertEqual(example_func.call_count, 1)
228+
called_event = example_func.call_args[0][0]
229+
self.assertEqual(called_event.job_name, "example-job")
230+
self.assertIsNotNone(called_event.schedule_time)
231+
self.assertIsNotNone(called_event.schedule_time.tzinfo)
232+
124233
def test_calls_init(self):
125234
hello = None
126235

0 commit comments

Comments
 (0)