diff --git a/src/firebase_functions/dataconnect_fn.py b/src/firebase_functions/dataconnect_fn.py index d11eb9a..a005c22 100644 --- a/src/firebase_functions/dataconnect_fn.py +++ b/src/firebase_functions/dataconnect_fn.py @@ -17,7 +17,6 @@ # pylint: disable=protected-access import dataclasses as _dataclass -import datetime as _dt import functools as _functools import typing as _typing @@ -209,7 +208,9 @@ def _dataconnect_endpoint_handler( ) -> None: # Currently, only mutationExecuted is supported if event_type != _event_type_mutation_executed: - raise NotImplementedError(f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported.") + raise NotImplementedError( + f"Unsupported event type: {event_type}. Only {_event_type_mutation_executed} is currently supported." + ) event_attributes = raw._get_attributes() event_data: _typing.Any = raw.get_data() @@ -224,15 +225,9 @@ def _dataconnect_endpoint_handler( if service_pattern: params = {**params, **service_pattern.extract_matches(event_service)} if connector_pattern: - params = { - **params, - **connector_pattern.extract_matches(event_connector) - } + params = {**params, **connector_pattern.extract_matches(event_connector)} if operation_pattern: - params = { - **params, - **operation_pattern.extract_matches(event_operation) - } + params = {**params, **operation_pattern.extract_matches(event_operation)} event_auth_type = event_attributes["authtype"] event_auth_id = event_attributes["authid"] @@ -282,12 +277,13 @@ def mutation_executed_handler(event: Event[MutationEventData]): options = DataConnectOptions(**kwargs) def on_mutation_executed_inner_decorator(func: _C1): - service_pattern = _path_pattern.PathPattern( - options.service) if options.service else None - connector_pattern = _path_pattern.PathPattern( - options.connector) if options.connector else None - operation_pattern = _path_pattern.PathPattern( - options.operation) if options.operation else None + service_pattern = _path_pattern.PathPattern(options.service) if options.service else None + connector_pattern = ( + _path_pattern.PathPattern(options.connector) if options.connector else None + ) + operation_pattern = ( + _path_pattern.PathPattern(options.operation) if options.operation else None + ) @_functools.wraps(func) def on_mutation_executed_wrapped(raw: _ce.CloudEvent): diff --git a/src/firebase_functions/options.py b/src/firebase_functions/options.py index c3ba16f..ee084cb 100644 --- a/src/firebase_functions/options.py +++ b/src/firebase_functions/options.py @@ -1188,22 +1188,22 @@ def _endpoint( event_filters_path_patterns: _typing.Any = {} if self.service: - if service_pattern.has_wildcards: - event_filters_path_patterns["service"] = service_pattern.value - else: - event_filters["service"] = service_pattern.value + if service_pattern.has_wildcards: + event_filters_path_patterns["service"] = service_pattern.value + else: + event_filters["service"] = service_pattern.value if self.connector: - if connector_pattern.has_wildcards: - event_filters_path_patterns["connector"] = connector_pattern.value - else: - event_filters["connector"] = connector_pattern.value + if connector_pattern.has_wildcards: + event_filters_path_patterns["connector"] = connector_pattern.value + else: + event_filters["connector"] = connector_pattern.value if self.operation: - if operation_pattern.has_wildcards: - event_filters_path_patterns["operation"] = operation_pattern.value - else: - event_filters["operation"] = operation_pattern.value + if operation_pattern.has_wildcards: + event_filters_path_patterns["operation"] = operation_pattern.value + else: + event_filters["operation"] = operation_pattern.value event_trigger = _manifest.EventTrigger( eventType=kwargs["event_type"], diff --git a/src/firebase_functions/scheduler_fn.py b/src/firebase_functions/scheduler_fn.py index 1979f67..995228d 100644 --- a/src/firebase_functions/scheduler_fn.py +++ b/src/firebase_functions/scheduler_fn.py @@ -17,6 +17,7 @@ import datetime as _dt import functools as _functools import typing as _typing +from datetime import timezone as _timezone from flask import ( Request as _Request, @@ -101,12 +102,27 @@ def on_schedule_wrapped(request: _Request) -> _Response: schedule_time: _dt.datetime schedule_time_str = request.headers.get("X-CloudScheduler-ScheduleTime") if schedule_time_str is None: - schedule_time = _dt.datetime.utcnow() + schedule_time = _dt.datetime.now(_timezone.utc) else: - schedule_time = _dt.datetime.strptime( - schedule_time_str, - "%Y-%m-%dT%H:%M:%S%z", - ) + try: + # Try to parse with the stdlib which supports fractional + # seconds and offsets in Python 3.11+ via fromisoformat. + # Normalize RFC3339 'Z' to '+00:00' for fromisoformat. + iso_str = schedule_time_str + if iso_str.endswith("Z"): + iso_str = iso_str[:-1] + "+00:00" + schedule_time = _dt.datetime.fromisoformat(iso_str) + except ValueError: + # Fallback to strict parsing without fractional seconds + try: + schedule_time = _dt.datetime.strptime( + schedule_time_str, + "%Y-%m-%dT%H:%M:%S%z", + ) + except ValueError as e: + # If all parsing fails, log and use current UTC time + _logging.exception(e) + schedule_time = _dt.datetime.now(_timezone.utc) event = ScheduledEvent( job_name=request.headers.get("X-CloudScheduler-JobName"), schedule_time=schedule_time, diff --git a/tests/test_dataconnect_fn.py b/tests/test_dataconnect_fn.py index 6fb55f6..907a336 100644 --- a/tests/test_dataconnect_fn.py +++ b/tests/test_dataconnect_fn.py @@ -132,7 +132,7 @@ def init(): "connector": "connector-id", "operation": "mutation-name", "authtype": "app_user", - "authid": "auth-id" + "authid": "auth-id", }, data=json.dumps({}), ) diff --git a/tests/test_scheduler_fn.py b/tests/test_scheduler_fn.py index b3a8c82..f3ad92f 100644 --- a/tests/test_scheduler_fn.py +++ b/tests/test_scheduler_fn.py @@ -14,8 +14,8 @@ """Scheduler function tests.""" import unittest -from datetime import datetime -from unittest.mock import Mock +from datetime import datetime, timezone +from unittest.mock import Mock, patch from flask import Flask, Request from werkzeug.test import EnvironBuilder @@ -81,6 +81,30 @@ def test_on_schedule_call(self): ) ) + def test_on_schedule_call_with_z_suffix(self): + """ + Tests to ensure that timestamps with 'Z' suffix are parsed correctly as UTC. + """ + with Flask(__name__).test_request_context("/"): + environ = EnvironBuilder( + headers={ + "X-CloudScheduler-JobName": "example-job", + "X-CloudScheduler-ScheduleTime": "2023-04-13T19:00:00Z", + } + ).get_environ() + mock_request = Request(environ) + example_func = Mock(__name__="example_func") + decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func) + response = decorated_func(mock_request) + + self.assertEqual(response.status_code, 200) + example_func.assert_called_once_with( + scheduler_fn.ScheduledEvent( + job_name="example-job", + schedule_time=datetime(2023, 4, 13, 19, 0, 0, tzinfo=timezone.utc), + ) + ) + def test_on_schedule_call_with_no_headers(self): """ Tests to ensure that if the function is called manually @@ -99,6 +123,7 @@ def test_on_schedule_call_with_no_headers(self): self.assertEqual(example_func.call_count, 1) self.assertIsNone(example_func.call_args[0][0].job_name) self.assertIsNotNone(example_func.call_args[0][0].schedule_time) + self.assertIsNotNone(example_func.call_args[0][0].schedule_time.tzinfo) def test_on_schedule_call_with_exception(self): """ @@ -121,6 +146,90 @@ def test_on_schedule_call_with_exception(self): self.assertEqual(response.status_code, 500) self.assertEqual(response.data, b"Test exception") + def test_on_schedule_call_with_fractional_seconds(self): + """ + Tests to ensure that timestamps with fractional seconds are parsed correctly. + """ + with Flask(__name__).test_request_context("/"): + environ = EnvironBuilder( + headers={ + "X-CloudScheduler-JobName": "example-job", + "X-CloudScheduler-ScheduleTime": "2023-04-13T19:00:00.123456Z", + } + ).get_environ() + mock_request = Request(environ) + example_func = Mock(__name__="example_func") + decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func) + response = decorated_func(mock_request) + + self.assertEqual(response.status_code, 200) + example_func.assert_called_once_with( + scheduler_fn.ScheduledEvent( + job_name="example-job", + schedule_time=datetime(2023, 4, 13, 19, 0, 0, 123456, tzinfo=timezone.utc), + ) + ) + + def test_on_schedule_call_fallback_parsing(self): + """ + Tests fallback parsing for formats that might fail fromisoformat + but pass strptime (e.g., offset without colon). + """ + with Flask(__name__).test_request_context("/"): + environ = EnvironBuilder( + headers={ + "X-CloudScheduler-JobName": "example-job", + # Offset without colon might fail fromisoformat in some versions + # but should pass strptime("%Y-%m-%dT%H:%M:%S%z") + "X-CloudScheduler-ScheduleTime": "2023-04-13T12:00:00-0700", + } + ).get_environ() + mock_request = Request(environ) + example_func = Mock(__name__="example_func") + decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func) + response = decorated_func(mock_request) + + self.assertEqual(response.status_code, 200) + + # Create expected datetime with fixed offset -07:00 + tz = datetime.strptime("-0700", "%z").tzinfo + expected_dt = datetime(2023, 4, 13, 12, 0, 0, tzinfo=tz) + + example_func.assert_called_once_with( + scheduler_fn.ScheduledEvent( + job_name="example-job", + schedule_time=expected_dt, + ) + ) + + def test_on_schedule_call_invalid_timestamp(self): + """ + Tests that invalid timestamps log an error and fallback to current time. + """ + with Flask(__name__).test_request_context("/"): + environ = EnvironBuilder( + headers={ + "X-CloudScheduler-JobName": "example-job", + "X-CloudScheduler-ScheduleTime": "invalid-timestamp", + } + ).get_environ() + mock_request = Request(environ) + example_func = Mock(__name__="example_func") + + with patch("firebase_functions.scheduler_fn._logging") as mock_logging: + decorated_func = scheduler_fn.on_schedule(schedule="* * * * *")(example_func) + response = decorated_func(mock_request) + + self.assertEqual(response.status_code, 200) + mock_logging.exception.assert_called_once() + + # Should have called with *some* time (current time), so we just check it's not None + self.assertEqual(example_func.call_count, 1) + called_event = example_func.call_args[0][0] + self.assertEqual(called_event.job_name, "example-job") + self.assertIsNotNone(called_event.schedule_time) + self.assertIsNotNone(called_event.schedule_time.tzinfo) + def test_calls_init(self): hello = None