From abb5d3e0f7f1fab4a1bc0ea4a05a557ffd3022af Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Mon, 24 Nov 2025 13:13:41 -0500 Subject: [PATCH 1/8] feat(functions): Enable Cloud Task Queue Emulator support --- firebase_admin/functions.py | 87 +++++++++++++++++++++++++++++++------ tests/test_functions.py | 84 +++++++++++++++++++++++++++-------- 2 files changed, 138 insertions(+), 33 deletions(-) diff --git a/firebase_admin/functions.py b/firebase_admin/functions.py index 8e77d856..97606555 100644 --- a/firebase_admin/functions.py +++ b/firebase_admin/functions.py @@ -18,6 +18,7 @@ from datetime import datetime, timedelta, timezone from urllib import parse import re +import os import json from base64 import b64encode from typing import Any, Optional, Dict @@ -49,6 +50,8 @@ 'https://cloudtasks.googleapis.com/v2/' + _CLOUD_TASKS_API_RESOURCE_PATH _FIREBASE_FUNCTION_URL_FORMAT = \ 'https://{location_id}-{project_id}.cloudfunctions.net/{resource_id}' +_EMULATOR_HOST_ENV_VAR = 'CLOUD_TASKS_EMULATOR_HOST' +_EMULATED_SERVICE_ACCOUNT_DEFAULT = 'emulated-service-acct@email.com' _FUNCTIONS_HEADERS = { 'X-GOOG-API-FORMAT-VERSION': '2', @@ -58,6 +61,17 @@ # Default canonical location ID of the task queue. _DEFAULT_LOCATION = 'us-central1' +def _get_emulator_host() -> Optional[str]: + emulator_host = os.environ.get(_EMULATOR_HOST_ENV_VAR) + if emulator_host: + if '//' in emulator_host: + raise ValueError( + f'Invalid {_EMULATOR_HOST_ENV_VAR}: "{emulator_host}". It must follow format ' + '"host:port".') + return emulator_host + return None + + def _get_functions_service(app) -> _FunctionsService: return _utils.get_app_service(app, _FUNCTIONS_ATTRIBUTE, _FunctionsService) @@ -103,7 +117,11 @@ def __init__(self, app: App): 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') - self._credential = app.credential.get_credential() + if _get_emulator_host(): + self._credential = _utils.EmulatorAdminCredentials() + else: + self._credential = app.credential.get_credential() + self._http_client = _http_client.JsonHttpClient(credential=self._credential) def task_queue(self, function_name: str, extension_id: Optional[str] = None) -> TaskQueue: @@ -167,16 +185,23 @@ def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str: str: The ID of the task relative to this queue. """ task = self._validate_task_options(task_data, self._resource, opts) - service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT) + emulator_url = self._get_emulator_url(self._resource) + service_url = emulator_url or self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT) task_payload = self._update_task_payload(task, self._resource, self._extension_id) try: resp = self._http_client.body( 'post', url=service_url, headers=_FUNCTIONS_HEADERS, - json={'task': task_payload.__dict__} + json={'task': task_payload._to_api_dict()} ) - task_name = resp.get('name', None) + if self._is_emulated(): + # Emulator returns a response with format {task: {name: }} + # The task name also has an extra '/' at the start compared to prod + task_name = resp.get('task').get('name')[1:] + else: + # Production returns a response with format {name: } + task_name = resp.get('name') task_resource = \ self._parse_resource_name(task_name, f'queues/{self._resource.resource_id}/tasks') return task_resource.resource_id @@ -196,8 +221,11 @@ def delete(self, task_id: str) -> None: the Cloud Functions service. ValueError: If the input arguments are invalid. """ - _Validators.check_non_empty_string('task_id', task_id) - service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}') + emulator_url = self._get_emulator_url(self._resource) + if emulator_url: + service_url = emulator_url + f'/{task_id}' + else: + service_url = self._get_url(self._resource, _CLOUD_TASKS_API_URL_FORMAT + f'/{task_id}') try: self._http_client.body( 'delete', @@ -235,8 +263,8 @@ def _validate_task_options( """Validate and create a Task from optional ``TaskOptions``.""" task_http_request = { 'url': '', - 'oidc_token': { - 'service_account_email': '' + 'oidcToken': { + 'serviceAccountEmail': '' }, 'body': b64encode(json.dumps(data).encode()).decode(), 'headers': { @@ -250,7 +278,7 @@ def _validate_task_options( task.http_request['headers'] = {**task.http_request['headers'], **opts.headers} if opts.schedule_time is not None and opts.schedule_delay_seconds is not None: raise ValueError( - 'Both sechdule_delay_seconds and schedule_time cannot be set at the same time.') + 'Both schedule_delay_seconds and schedule_time cannot be set at the same time.') if opts.schedule_time is not None and opts.schedule_delay_seconds is None: if not isinstance(opts.schedule_time, datetime): raise ValueError('schedule_time should be UTC datetime.') @@ -288,7 +316,10 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str """Prepares task to be sent with credentials.""" # Get function url from task or generate from resources if not _Validators.is_non_empty_string(task.http_request['url']): - task.http_request['url'] = self._get_url(resource, _FIREBASE_FUNCTION_URL_FORMAT) + if self._is_emulated(): + task.http_request['url'] = '' + else: + task.http_request['url'] = self._get_url(resource, _FIREBASE_FUNCTION_URL_FORMAT) # Refresh the credential to ensure all attributes (e.g. service_account_email, id_token) # are populated, preventing cold start errors. @@ -298,7 +329,7 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str except RefreshError as err: raise ValueError(f'Initial task payload credential refresh failed: {err}') from err - # If extension id is provided, it emplies that it is being run from a deployed extension. + # If extension id is provided, it implies that it is being run from a deployed extension. # Meaning that it's credential should be a Compute Engine Credential. if _Validators.is_non_empty_string(extension_id) and \ isinstance(self._credential, ComputeEngineCredentials): @@ -306,11 +337,31 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str task.http_request['headers'] = \ {**task.http_request['headers'], 'Authorization': f'Bearer {id_token}'} # Delete oidc token - del task.http_request['oidc_token'] + del task.http_request['oidcToken'] else: - task.http_request['oidc_token'] = \ - {'service_account_email': self._credential.service_account_email} + try: + task.http_request['oidcToken'] = \ + {'serviceAccountEmail': self._credential.service_account_email} + except AttributeError as e: + if self._is_emulated(): + task.http_request['oidcToken'] = \ + {'serviceAccountEmail': _EMULATED_SERVICE_ACCOUNT_DEFAULT} + else: + raise ValueError( + 'Failed to determine service account. Initialize the SDK with service account ' + 'credentials or set service account ID as an app option.') from e return task + + def _get_emulator_url(self, resource: Resource): + emulator_host = _get_emulator_host() + if emulator_host: + emulator_url_format = f'http://{emulator_host}/' + _CLOUD_TASKS_API_RESOURCE_PATH + url = self._get_url(resource, emulator_url_format) + return url + return None + + def _is_emulated(self): + return _get_emulator_host() is not None class _Validators: @@ -436,6 +487,14 @@ class Task: schedule_time: Optional[str] = None dispatch_deadline: Optional[str] = None + def _to_api_dict(self) -> dict: + """Converts the Task object to a dictionary suitable for the Cloud Tasks API.""" + return { + 'httpRequest': self.http_request, + 'name': self.name, + 'scheduleTime': self.schedule_time, + 'dispatchDeadline': self.dispatch_deadline, + } @dataclass class Resource: diff --git a/tests/test_functions.py b/tests/test_functions.py index 95356344..4241fb4b 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -16,6 +16,7 @@ from datetime import datetime, timedelta, timezone import json +import os import time import pytest @@ -44,13 +45,13 @@ def setup_class(cls): def teardown_class(cls): testutils.cleanup_apps() - def _instrument_functions_service(self, app=None, status=200, payload=_DEFAULT_RESPONSE): + def _instrument_functions_service(self, app=None, status=200, payload=_DEFAULT_RESPONSE, mounted_url=_CLOUD_TASKS_URL): if not app: app = firebase_admin.get_app() functions_service = functions._get_functions_service(app) recorder = [] functions_service._http_client.session.mount( - _CLOUD_TASKS_URL, + mounted_url, testutils.MockAdapter(payload, status, recorder)) return functions_service, recorder @@ -125,8 +126,8 @@ def test_task_enqueue(self): assert task_id == 'test-task-id' task = json.loads(recorder[0].body.decode())['task'] - assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-email'} - assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} def test_task_enqueue_with_extension(self): resource_name = ( @@ -147,8 +148,8 @@ def test_task_enqueue_with_extension(self): assert task_id == 'test-task-id' task = json.loads(recorder[0].body.decode())['task'] - assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-email'} - assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} def test_task_enqueue_compute_engine(self): app = firebase_admin.initialize_app( @@ -168,8 +169,8 @@ def test_task_enqueue_compute_engine(self): assert task_id == 'test-task-id' task = json.loads(recorder[0].body.decode())['task'] - assert task['http_request']['oidc_token'] == {'service_account_email': 'mock-gce-email'} - assert task['http_request']['headers'] == {'Content-Type': 'application/json'} + assert task['httpRequest']['oidcToken'] == {'serviceAccountEmail': 'mock-gce-email'} + assert task['httpRequest']['headers'] == {'Content-Type': 'application/json'} def test_task_enqueue_with_extension_compute_engine(self): resource_name = ( @@ -194,8 +195,8 @@ def test_task_enqueue_with_extension_compute_engine(self): assert task_id == 'test-task-id' task = json.loads(recorder[0].body.decode())['task'] - assert 'oidc_token' not in task['http_request'] - assert task['http_request']['headers'] == { + assert 'oidcToken' not in task['httpRequest'] + assert task['httpRequest']['headers'] == { 'Content-Type': 'application/json', 'Authorization': 'Bearer mock-compute-engine-token'} @@ -209,6 +210,51 @@ def test_task_delete(self): expected_metrics_header = _utils.get_metrics_header() + ' mock-cred-metric-tag' assert recorder[0].headers['x-goog-api-client'] == expected_metrics_header + def test_task_enqueue_with_emulator_host(self, monkeypatch): + emulator_host = 'localhost:8124' + emulator_url = f'http://{emulator_host}/' + request_url = emulator_url + _DEFAULT_TASK_PATH.replace('/tasks/test-task-id', '/tasks') + + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', emulator_host) + app = firebase_admin.initialize_app(_utils.EmulatorAdminCredentials(), {'projectId': 'test-project'}, name='emulator-app') + + expected_response = json.dumps({'task': {'name': '/projects/test-project/locations/us-central1/queues/test-function-name/tasks/test-task-id'}}) + _, recorder = self._instrument_functions_service(app, payload=expected_response, mounted_url=emulator_url) + + queue = functions.task_queue('test-function-name', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + + assert len(recorder) == 1 + assert recorder[0].method == 'POST' + assert recorder[0].url == request_url + assert recorder[0].headers['Content-Type'] == 'application/json' + + task = json.loads(recorder[0].body.decode())['task'] + assert task['httpRequest']['oidcToken'] == { + 'serviceAccountEmail': 'emulated-service-acct@email.com' + } + assert task_id == 'test-task-id' + + def test_task_enqueue_without_emulator_host_error(self, monkeypatch): + app = firebase_admin.initialize_app(_utils.EmulatorAdminCredentials(), {'projectId': 'test-project'}, name='no-emulator-app') + + _, recorder = self._instrument_functions_service(app) + monkeypatch.delenv('CLOUD_TASKS_EMULATOR_HOST', raising=False) + queue = functions.task_queue('test-function-name', app=app) + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA) + assert "Failed to determine service account" in str(excinfo.value) + assert len(recorder) == 0 + + def test_get_emulator_url_invalid_format(self, monkeypatch): + _, recorder = self._instrument_functions_service() + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', 'http://localhost:8124') + queue = functions.task_queue('test-function-name') + with pytest.raises(ValueError) as excinfo: + queue.enqueue(_DEFAULT_DATA) + assert 'Invalid CLOUD_TASKS_EMULATOR_HOST' in str(excinfo.value) + assert len(recorder) == 0 + class TestTaskQueueOptions: _DEFAULT_TASK_OPTS = {'schedule_delay_seconds': None, 'schedule_time': None, \ @@ -259,13 +305,13 @@ def test_task_options_delay_seconds(self): assert len(recorder) == 1 task = json.loads(recorder[0].body.decode())['task'] - task_schedule_time = datetime.fromisoformat(task['schedule_time'].replace('Z', '+00:00')) + task_schedule_time = datetime.fromisoformat(task['scheduleTime'].replace('Z', '+00:00')) delta = abs(task_schedule_time - expected_schedule_time) assert delta <= timedelta(seconds=1) - assert task['dispatch_deadline'] == '200s' - assert task['http_request']['headers']['x-test-header'] == 'test-header-value' - assert task['http_request']['url'] in ['http://google.com', 'https://google.com'] + assert task['dispatchDeadline'] == '200s' + assert task['httpRequest']['headers']['x-test-header'] == 'test-header-value' + assert task['httpRequest']['url'] in ['http://google.com', 'https://google.com'] assert task['name'] == _DEFAULT_TASK_PATH def test_task_options_utc_time(self): @@ -287,12 +333,12 @@ def test_task_options_utc_time(self): assert len(recorder) == 1 task = json.loads(recorder[0].body.decode())['task'] - task_schedule_time = datetime.fromisoformat(task['schedule_time'].replace('Z', '+00:00')) + task_schedule_time = datetime.fromisoformat(task['scheduleTime'].replace('Z', '+00:00')) assert task_schedule_time == expected_schedule_time - assert task['dispatch_deadline'] == '200s' - assert task['http_request']['headers']['x-test-header'] == 'test-header-value' - assert task['http_request']['url'] in ['http://google.com', 'https://google.com'] + assert task['dispatchDeadline'] == '200s' + assert task['httpRequest']['headers']['x-test-header'] == 'test-header-value' + assert task['httpRequest']['url'] in ['http://google.com', 'https://google.com'] assert task['name'] == _DEFAULT_TASK_PATH def test_schedule_set_twice_error(self): @@ -304,7 +350,7 @@ def test_schedule_set_twice_error(self): queue.enqueue(_DEFAULT_DATA, opts) assert len(recorder) == 0 assert str(excinfo.value) == \ - 'Both sechdule_delay_seconds and schedule_time cannot be set at the same time.' + 'Both schedule_delay_seconds and schedule_time cannot be set at the same time.' @pytest.mark.parametrize('schedule_time', [ From 43f956a19b58eb199b03df19157ea6f5123b1f19 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Mon, 24 Nov 2025 13:36:36 -0500 Subject: [PATCH 2/8] fix: lint --- firebase_admin/functions.py | 17 +++++++++-------- tests/test_functions.py | 32 ++++++++++++++++++++------------ 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/firebase_admin/functions.py b/firebase_admin/functions.py index 97606555..f98e8c58 100644 --- a/firebase_admin/functions.py +++ b/firebase_admin/functions.py @@ -193,7 +193,7 @@ def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str: 'post', url=service_url, headers=_FUNCTIONS_HEADERS, - json={'task': task_payload._to_api_dict()} + json={'task': task_payload.to_api_dict()} ) if self._is_emulated(): # Emulator returns a response with format {task: {name: }} @@ -339,19 +339,20 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str # Delete oidc token del task.http_request['oidcToken'] else: - try: + try: task.http_request['oidcToken'] = \ {'serviceAccountEmail': self._credential.service_account_email} - except AttributeError as e: + except AttributeError as error: if self._is_emulated(): task.http_request['oidcToken'] = \ {'serviceAccountEmail': _EMULATED_SERVICE_ACCOUNT_DEFAULT} else: raise ValueError( - 'Failed to determine service account. Initialize the SDK with service account ' - 'credentials or set service account ID as an app option.') from e + 'Failed to determine service account. Initialize the SDK with service ' + 'account credentials or set service account ID as an app option.' + ) from error return task - + def _get_emulator_url(self, resource: Resource): emulator_host = _get_emulator_host() if emulator_host: @@ -359,7 +360,7 @@ def _get_emulator_url(self, resource: Resource): url = self._get_url(resource, emulator_url_format) return url return None - + def _is_emulated(self): return _get_emulator_host() is not None @@ -487,7 +488,7 @@ class Task: schedule_time: Optional[str] = None dispatch_deadline: Optional[str] = None - def _to_api_dict(self) -> dict: + def to_api_dict(self) -> dict: """Converts the Task object to a dictionary suitable for the Cloud Tasks API.""" return { 'httpRequest': self.http_request, diff --git a/tests/test_functions.py b/tests/test_functions.py index 4241fb4b..9fcfbd37 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -16,7 +16,6 @@ from datetime import datetime, timedelta, timezone import json -import os import time import pytest @@ -45,7 +44,8 @@ def setup_class(cls): def teardown_class(cls): testutils.cleanup_apps() - def _instrument_functions_service(self, app=None, status=200, payload=_DEFAULT_RESPONSE, mounted_url=_CLOUD_TASKS_URL): + def _instrument_functions_service( + self, app=None, status=200, payload=_DEFAULT_RESPONSE, mounted_url=_CLOUD_TASKS_URL): if not app: app = firebase_admin.get_app() functions_service = functions._get_functions_service(app) @@ -214,21 +214,27 @@ def test_task_enqueue_with_emulator_host(self, monkeypatch): emulator_host = 'localhost:8124' emulator_url = f'http://{emulator_host}/' request_url = emulator_url + _DEFAULT_TASK_PATH.replace('/tasks/test-task-id', '/tasks') - + monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', emulator_host) - app = firebase_admin.initialize_app(_utils.EmulatorAdminCredentials(), {'projectId': 'test-project'}, name='emulator-app') - - expected_response = json.dumps({'task': {'name': '/projects/test-project/locations/us-central1/queues/test-function-name/tasks/test-task-id'}}) - _, recorder = self._instrument_functions_service(app, payload=expected_response, mounted_url=emulator_url) + app = firebase_admin.initialize_app( + _utils.EmulatorAdminCredentials(), {'projectId': 'test-project'}, name='emulator-app') + + expected_task_name = ( + '/projects/test-project/locations/us-central1' + '/queues/test-function-name/tasks/test-task-id' + ) + expected_response = json.dumps({'task': {'name': expected_task_name}}) + _, recorder = self._instrument_functions_service( + app, payload=expected_response, mounted_url=emulator_url) queue = functions.task_queue('test-function-name', app=app) task_id = queue.enqueue(_DEFAULT_DATA) - + assert len(recorder) == 1 assert recorder[0].method == 'POST' assert recorder[0].url == request_url assert recorder[0].headers['Content-Type'] == 'application/json' - + task = json.loads(recorder[0].body.decode())['task'] assert task['httpRequest']['oidcToken'] == { 'serviceAccountEmail': 'emulated-service-acct@email.com' @@ -236,15 +242,17 @@ def test_task_enqueue_with_emulator_host(self, monkeypatch): assert task_id == 'test-task-id' def test_task_enqueue_without_emulator_host_error(self, monkeypatch): - app = firebase_admin.initialize_app(_utils.EmulatorAdminCredentials(), {'projectId': 'test-project'}, name='no-emulator-app') - + app = firebase_admin.initialize_app( + _utils.EmulatorAdminCredentials(), + {'projectId': 'test-project'}, name='no-emulator-app') + _, recorder = self._instrument_functions_service(app) monkeypatch.delenv('CLOUD_TASKS_EMULATOR_HOST', raising=False) queue = functions.task_queue('test-function-name', app=app) with pytest.raises(ValueError) as excinfo: queue.enqueue(_DEFAULT_DATA) assert "Failed to determine service account" in str(excinfo.value) - assert len(recorder) == 0 + assert len(recorder) == 0 def test_get_emulator_url_invalid_format(self, monkeypatch): _, recorder = self._instrument_functions_service() From 9740962920567ac55c95b1e9a4c4d72a5b67ac4a Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Mon, 24 Nov 2025 14:34:21 -0500 Subject: [PATCH 3/8] fix: Resolved issues from gemini review --- firebase_admin/functions.py | 23 +++++++++++++++-------- tests/test_functions.py | 7 +++---- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/firebase_admin/functions.py b/firebase_admin/functions.py index f98e8c58..66ba700b 100644 --- a/firebase_admin/functions.py +++ b/firebase_admin/functions.py @@ -117,7 +117,8 @@ def __init__(self, app: App): 'projectId option, or use service account credentials. Alternatively, set the ' 'GOOGLE_CLOUD_PROJECT environment variable.') - if _get_emulator_host(): + self._emulator_host = _get_emulator_host() + if self._emulator_host: self._credential = _utils.EmulatorAdminCredentials() else: self._credential = app.credential.get_credential() @@ -127,7 +128,8 @@ def __init__(self, app: App): def task_queue(self, function_name: str, extension_id: Optional[str] = None) -> TaskQueue: """Creates a TaskQueue instance.""" return TaskQueue( - function_name, extension_id, self._project_id, self._credential, self._http_client) + function_name, extension_id, self._project_id, self._credential, self._http_client, + self._emulator_host) @classmethod def handle_functions_error(cls, error: Any): @@ -143,7 +145,8 @@ def __init__( extension_id: Optional[str], project_id, credential, - http_client + http_client, + emulator_host: Optional[str] = None ) -> None: # Validate function_name @@ -152,6 +155,7 @@ def __init__( self._project_id = project_id self._credential = credential self._http_client = http_client + self._emulator_host = emulator_host self._function_name = function_name self._extension_id = extension_id # Parse resources from function_name @@ -198,7 +202,10 @@ def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str: if self._is_emulated(): # Emulator returns a response with format {task: {name: }} # The task name also has an extra '/' at the start compared to prod - task_name = resp.get('task').get('name')[1:] + task_info = resp.get('task') or {} + task_name = task_info.get('name') + if task_name: + task_name = task_name[1:] else: # Production returns a response with format {name: } task_name = resp.get('name') @@ -221,6 +228,7 @@ def delete(self, task_id: str) -> None: the Cloud Functions service. ValueError: If the input arguments are invalid. """ + _Validators.check_non_empty_string('task_id', task_id) emulator_url = self._get_emulator_url(self._resource) if emulator_url: service_url = emulator_url + f'/{task_id}' @@ -354,15 +362,14 @@ def _update_task_payload(self, task: Task, resource: Resource, extension_id: str return task def _get_emulator_url(self, resource: Resource): - emulator_host = _get_emulator_host() - if emulator_host: - emulator_url_format = f'http://{emulator_host}/' + _CLOUD_TASKS_API_RESOURCE_PATH + if self._emulator_host: + emulator_url_format = f'http://{self._emulator_host}/' + _CLOUD_TASKS_API_RESOURCE_PATH url = self._get_url(resource, emulator_url_format) return url return None def _is_emulated(self): - return _get_emulator_host() is not None + return self._emulator_host is not None class _Validators: diff --git a/tests/test_functions.py b/tests/test_functions.py index 9fcfbd37..0f766767 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -255,13 +255,12 @@ def test_task_enqueue_without_emulator_host_error(self, monkeypatch): assert len(recorder) == 0 def test_get_emulator_url_invalid_format(self, monkeypatch): - _, recorder = self._instrument_functions_service() monkeypatch.setenv('CLOUD_TASKS_EMULATOR_HOST', 'http://localhost:8124') - queue = functions.task_queue('test-function-name') + app = firebase_admin.initialize_app( + testutils.MockCredential(), {'projectId': 'test-project'}, name='invalid-host-app') with pytest.raises(ValueError) as excinfo: - queue.enqueue(_DEFAULT_DATA) + functions.task_queue('test-function-name', app=app) assert 'Invalid CLOUD_TASKS_EMULATOR_HOST' in str(excinfo.value) - assert len(recorder) == 0 class TestTaskQueueOptions: From 9cdf76a1da2a9f024dcc068c65785d156c3a52ea Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 25 Nov 2025 15:31:54 -0500 Subject: [PATCH 4/8] chore: Added basic integration tests for task enqueue and delete --- CONTRIBUTING.md | 11 +++++++++++ integration/test_functions.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 72933a24..71da12dc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -252,6 +252,17 @@ to ensure that exported user records contain the password hashes of the user acc 3. Click **ADD ANOTHER ROLE** and choose **Firebase Authentication Admin**. 4. Click **SAVE**. +9. Enable Cloud Tasks: + 1. Search for and enable **Cloud Run**. + 2. Search for and enable **Cloud Tasks**. + 3. Go to [Google Cloud console | IAM & admin](https://console.cloud.google.com/iam-admin) + and make sure your Firebase project is selected. + 4. Ensure your service account has the following required roles: + * **Cloud Tasks Enqueuer** - `cloudtasks.taskEnqueuer` + * **Cloud Tasks Task Deleter** - `cloudtasks.taskDeleter` + * **Cloud Run Invoker** - `run.invoker` + * **Service Account User** - `iam.serviceAccountUser` + Now you can invoke the integration test suite as follows: diff --git a/integration/test_functions.py b/integration/test_functions.py index 60679843..8947e0c1 100644 --- a/integration/test_functions.py +++ b/integration/test_functions.py @@ -21,6 +21,8 @@ from integration import conftest +_DEFAULT_DATA = {'data': {'city': 'Seattle'}} + @pytest.fixture(scope='module') def app(request): cred, _ = conftest.integration_conf(request) @@ -54,3 +56,21 @@ def test_task_queue_app(self, task_queue_params, app): assert queue is not None assert callable(queue.enqueue) assert callable(queue.delete) + + def test_task_enqueue(self, app): + queue = functions.task_queue('testTaskQueue', app=app) + task_id = queue.enqueue(_DEFAULT_DATA) + assert task_id is not None + + def test_task_delete(self, app): + # Skip this test against the emulator since tasks can't be delayed there to verify deletion + # See: https://github.com/firebase/firebase-tools/issues/8254 + task_options = functions.TaskOptions(schedule_delay_seconds=60) + queue = functions.task_queue('testTaskQueue', app=app) + task_id = queue.enqueue(_DEFAULT_DATA, task_options) + assert task_id is not None + queue.delete(task_id) + # We don't have a way to check the contents of the queue so we check that the deleted + # task is not found using the delete method again. + with pytest.raises(firebase_admin.exceptions.NotFoundError): + queue.delete(task_id) From c3a045c7efdd5d7aa2f2b1708eaae7f41c7b047b Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 25 Nov 2025 17:47:30 -0500 Subject: [PATCH 5/8] chore: Setup emulator testing for Functions integration tests --- .github/workflows/ci.yml | 23 +++++-- integration/emulators/.gitignore | 69 +++++++++++++++++++ integration/emulators/firebase.json | 29 ++++++++ integration/emulators/functions/.gitignore | 6 ++ integration/emulators/functions/main.py | 7 ++ .../emulators/functions/requirements.txt | 1 + integration/test_functions.py | 36 +++++++--- 7 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 integration/emulators/.gitignore create mode 100644 integration/emulators/firebase.json create mode 100644 integration/emulators/functions/.gitignore create mode 100644 integration/emulators/functions/main.py create mode 100644 integration/emulators/functions/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bfd29e2c..117c8860 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,6 +12,17 @@ jobs: steps: - uses: actions/checkout@v4 + + - name: Set up Python 3.13 for emulator + uses: actions/setup-python@v5 + with: + python-version: '3.13' + - name: Setup functions emulator environment + run: | + python -m venv integration/emulators/functions/venv + source integration/emulators/functions/venv/bin/activate + pip install -r integration/emulators/functions/requirements.txt + deactivate - name: Set up Python ${{ matrix.python }} uses: actions/setup-python@v5 with: @@ -26,12 +37,12 @@ jobs: uses: actions/setup-node@v4 with: node-version: 20 - - name: Run integration tests against emulator - run: | - npm install -g firebase-tools - firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' - - lint: + - name: Install firebase-tools + run: npm install -g firebase-tools + - name: Run Database emulator tests + run: firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' + - name: Run Functions emulator tests + run: firebase emulators:exec --config integration/emulators/firebase.json --only tasks,functions --project fake-project-id 'CLOUD_TASKS_EMULATOR_HOST=localhost:9499 pytest integration/test_functions.py' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/integration/emulators/.gitignore b/integration/emulators/.gitignore new file mode 100644 index 00000000..b17f6310 --- /dev/null +++ b/integration/emulators/.gitignore @@ -0,0 +1,69 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +firebase-debug.log* +firebase-debug.*.log* + +# Firebase cache +.firebase/ + +# Firebase config + +# Uncomment this if you'd like others to create their own Firebase project. +# For a team working on the same Firebase project(s), it is recommended to leave +# it commented so all members can deploy to the same project(s) in .firebaserc. +# .firebaserc + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env + +# dataconnect generated files +.dataconnect diff --git a/integration/emulators/firebase.json b/integration/emulators/firebase.json new file mode 100644 index 00000000..a7b727c4 --- /dev/null +++ b/integration/emulators/firebase.json @@ -0,0 +1,29 @@ +{ + "emulators": { + "tasks": { + "port": 9499 + }, + "ui": { + "enabled": false + }, + "singleProjectMode": true, + "functions": { + "port": 5001 + } + }, + "functions": [ + { + "source": "functions", + "codebase": "default", + "disallowLegacyRuntimeConfig": true, + "ignore": [ + "venv", + ".git", + "firebase-debug.log", + "firebase-debug.*.log", + "*.local" + ], + "runtime": "python313" + } + ] +} diff --git a/integration/emulators/functions/.gitignore b/integration/emulators/functions/.gitignore new file mode 100644 index 00000000..1609bab7 --- /dev/null +++ b/integration/emulators/functions/.gitignore @@ -0,0 +1,6 @@ +# Python bytecode +__pycache__/ + +# Python virtual environment +venv/ +*.local diff --git a/integration/emulators/functions/main.py b/integration/emulators/functions/main.py new file mode 100644 index 00000000..4861e96d --- /dev/null +++ b/integration/emulators/functions/main.py @@ -0,0 +1,7 @@ +from firebase_functions import tasks_fn + +@tasks_fn.on_task_dispatched() +def testTaskQueue(req: tasks_fn.CallableRequest) -> None: + """Handles tasks from the task queue.""" + print(f"Received task with data: {req.data}") + return \ No newline at end of file diff --git a/integration/emulators/functions/requirements.txt b/integration/emulators/functions/requirements.txt new file mode 100644 index 00000000..65070b31 --- /dev/null +++ b/integration/emulators/functions/requirements.txt @@ -0,0 +1 @@ +firebase_functions~=0.4.1 \ No newline at end of file diff --git a/integration/test_functions.py b/integration/test_functions.py index 8947e0c1..901a7d1e 100644 --- a/integration/test_functions.py +++ b/integration/test_functions.py @@ -14,19 +14,35 @@ """Integration tests for firebase_admin.functions module.""" +import os import pytest import firebase_admin from firebase_admin import functions +from firebase_admin import _utils from integration import conftest _DEFAULT_DATA = {'data': {'city': 'Seattle'}} +def integration_conf(request): + host_override = os.environ.get('CLOUD_TASKS_EMULATOR_HOST') + if host_override: + return _utils.EmulatorAdminCredentials(), 'fake-project-id' + + return conftest.integration_conf(request) @pytest.fixture(scope='module') def app(request): - cred, _ = conftest.integration_conf(request) - return firebase_admin.initialize_app(cred, name='integration-functions') + cred, project_id = integration_conf(request) + return firebase_admin.initialize_app( + cred, options={'projectId': project_id}, name='integration-functions') + +@pytest.fixture(scope='module', autouse=True) +def default_app(request): + cred, project_id = integration_conf(request) + app = firebase_admin.initialize_app(cred, options={'projectId': project_id}) + yield app + firebase_admin.delete_app(app) class TestFunctions: @@ -56,17 +72,21 @@ def test_task_queue_app(self, task_queue_params, app): assert queue is not None assert callable(queue.enqueue) assert callable(queue.delete) - - def test_task_enqueue(self, app): - queue = functions.task_queue('testTaskQueue', app=app) + + def test_task_enqueue(self): + queue = functions.task_queue('testTaskQueue') task_id = queue.enqueue(_DEFAULT_DATA) assert task_id is not None - - def test_task_delete(self, app): + + @pytest.mark.skipif( + os.environ.get('CLOUD_TASKS_EMULATOR_HOST') is not None, + reason="Skipping test_task_delete against emulator due to bug in firebase-tools" + ) + def test_task_delete(self): # Skip this test against the emulator since tasks can't be delayed there to verify deletion # See: https://github.com/firebase/firebase-tools/issues/8254 task_options = functions.TaskOptions(schedule_delay_seconds=60) - queue = functions.task_queue('testTaskQueue', app=app) + queue = functions.task_queue('testTaskQueue') task_id = queue.enqueue(_DEFAULT_DATA, task_options) assert task_id is not None queue.delete(task_id) From e513613b92c1949a4e940337ae55519f4dc49515 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 25 Nov 2025 17:53:30 -0500 Subject: [PATCH 6/8] fix: Re-added accidentally removed lint --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 117c8860..2ba09880 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,7 @@ jobs: run: firebase emulators:exec --only database --project fake-project-id 'pytest integration/test_db.py' - name: Run Functions emulator tests run: firebase emulators:exec --config integration/emulators/firebase.json --only tasks,functions --project fake-project-id 'CLOUD_TASKS_EMULATOR_HOST=localhost:9499 pytest integration/test_functions.py' + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 From cce6277723a9626d0a50d3b17b537b0d07a9e25d Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Tue, 25 Nov 2025 18:30:24 -0500 Subject: [PATCH 7/8] fix: integration test default apps --- integration/test_functions.py | 24 ++++++++---------------- 1 file changed, 8 insertions(+), 16 deletions(-) diff --git a/integration/test_functions.py b/integration/test_functions.py index 901a7d1e..ddf670e2 100644 --- a/integration/test_functions.py +++ b/integration/test_functions.py @@ -39,10 +39,9 @@ def app(request): @pytest.fixture(scope='module', autouse=True) def default_app(request): - cred, project_id = integration_conf(request) - app = firebase_admin.initialize_app(cred, options={'projectId': project_id}) - yield app - firebase_admin.delete_app(app) + # Overwrites the default_app fixture in conftest.py. + # This test suite should not use the default app. Use the app fixture instead. + pass class TestFunctions: @@ -59,22 +58,15 @@ class TestFunctions: ] @pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS) - def test_task_queue(self, task_queue_params): - queue = functions.task_queue(**task_queue_params) - assert queue is not None - assert callable(queue.enqueue) - assert callable(queue.delete) - - @pytest.mark.parametrize('task_queue_params', _TEST_FUNCTIONS_PARAMS) - def test_task_queue_app(self, task_queue_params, app): + def test_task_queue(self, task_queue_params, app): assert app.name == 'integration-functions' queue = functions.task_queue(**task_queue_params, app=app) assert queue is not None assert callable(queue.enqueue) assert callable(queue.delete) - def test_task_enqueue(self): - queue = functions.task_queue('testTaskQueue') + def test_task_enqueue(self, app): + queue = functions.task_queue('testTaskQueue', app=app) task_id = queue.enqueue(_DEFAULT_DATA) assert task_id is not None @@ -82,11 +74,11 @@ def test_task_enqueue(self): os.environ.get('CLOUD_TASKS_EMULATOR_HOST') is not None, reason="Skipping test_task_delete against emulator due to bug in firebase-tools" ) - def test_task_delete(self): + def test_task_delete(self, app): # Skip this test against the emulator since tasks can't be delayed there to verify deletion # See: https://github.com/firebase/firebase-tools/issues/8254 task_options = functions.TaskOptions(schedule_delay_seconds=60) - queue = functions.task_queue('testTaskQueue') + queue = functions.task_queue('testTaskQueue', app=app) task_id = queue.enqueue(_DEFAULT_DATA, task_options) assert task_id is not None queue.delete(task_id) From d38751bfc40f0c628daafc9d72ae4776cd703a14 Mon Sep 17 00:00:00 2001 From: jonathanedey Date: Wed, 26 Nov 2025 10:32:03 -0500 Subject: [PATCH 8/8] fix: lint --- integration/emulators/functions/main.py | 2 +- integration/emulators/functions/requirements.txt | 2 +- integration/test_functions.py | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/integration/emulators/functions/main.py b/integration/emulators/functions/main.py index 4861e96d..6cd2c576 100644 --- a/integration/emulators/functions/main.py +++ b/integration/emulators/functions/main.py @@ -4,4 +4,4 @@ def testTaskQueue(req: tasks_fn.CallableRequest) -> None: """Handles tasks from the task queue.""" print(f"Received task with data: {req.data}") - return \ No newline at end of file + return diff --git a/integration/emulators/functions/requirements.txt b/integration/emulators/functions/requirements.txt index 65070b31..6bbab42f 100644 --- a/integration/emulators/functions/requirements.txt +++ b/integration/emulators/functions/requirements.txt @@ -1 +1 @@ -firebase_functions~=0.4.1 \ No newline at end of file +firebase_functions~=0.4.1 diff --git a/integration/test_functions.py b/integration/test_functions.py index ddf670e2..fc972f9e 100644 --- a/integration/test_functions.py +++ b/integration/test_functions.py @@ -38,7 +38,7 @@ def app(request): cred, options={'projectId': project_id}, name='integration-functions') @pytest.fixture(scope='module', autouse=True) -def default_app(request): +def default_app(): # Overwrites the default_app fixture in conftest.py. # This test suite should not use the default app. Use the app fixture instead. pass @@ -78,7 +78,7 @@ def test_task_delete(self, app): # Skip this test against the emulator since tasks can't be delayed there to verify deletion # See: https://github.com/firebase/firebase-tools/issues/8254 task_options = functions.TaskOptions(schedule_delay_seconds=60) - queue = functions.task_queue('testTaskQueue', app=app) + queue = functions.task_queue('testTaskQueue', app=app) task_id = queue.enqueue(_DEFAULT_DATA, task_options) assert task_id is not None queue.delete(task_id)