1919from urllib import parse
2020import re
2121import os
22+ import warnings
2223import json
2324from base64 import b64encode
24- from typing import Any , Optional , Dict
25+ from typing import Any , Optional , Dict , Union
2526from dataclasses import dataclass
2627
2728from google .auth .compute_engine import Credentials as ComputeEngineCredentials
3839_FUNCTIONS_ATTRIBUTE = '_functions'
3940
4041__all__ = [
42+ 'FunctionScope' ,
4143 'TaskOptions' ,
4244
4345 'task_queue' ,
4446]
4547
4648
49+ class FunctionScope :
50+ """Defines the scope of a function in a task queue."""
51+
52+ def __init__ (self , type_ : str , instance_id : Optional [str ] = None ):
53+ if type_ not in (
54+ 'current' , 'global' , 'extension' , 'kit' , 'extension_or_kit' ):
55+ # "kit" is not released yet and "extension_or_kit" is an implementation detail,
56+ # so they are not included in the public list of options.
57+ raise ValueError (
58+ 'type_ must be one of "current", "global", or "extension".'
59+ )
60+ if type_ in ('extension' , 'kit' , 'extension_or_kit' ):
61+ if not isinstance (instance_id , str ):
62+ raise ValueError ('instance_id must be a string.' )
63+ if instance_id == '' :
64+ raise ValueError ('instance_id must be a non-empty string.' )
65+ else :
66+ if instance_id is not None :
67+ raise ValueError (
68+ f'instance_id must not be set for type "{ type_ } ".'
69+ )
70+ self .type = type_
71+ self .instance_id = instance_id
72+
73+ @classmethod
74+ def current (cls ):
75+ """Creates a FunctionScope targeting the current extension or kit context."""
76+ return cls ("current" )
77+
78+ @classmethod
79+ def global_scope (cls ):
80+ """Creates a FunctionScope targeting a global function."""
81+ return cls ("global" )
82+
83+ @classmethod
84+ def extension (cls , extension_id : str ):
85+ """Creates a FunctionScope targeting a specific extension instance."""
86+ return cls ("extension" , instance_id = extension_id )
87+
88+
4789_CLOUD_TASKS_API_RESOURCE_PATH = \
4890 'projects/{project_id}/locations/{location_id}/queues/{resource_id}/tasks'
4991_CLOUD_TASKS_API_URL_FORMAT = \
@@ -77,8 +119,9 @@ def _get_functions_service(app) -> _FunctionsService:
77119
78120def task_queue (
79121 function_name : str ,
80- extension_id : Optional [str ] = None ,
81- app : Optional [App ] = None
122+ scope : Optional [Union [str , FunctionScope ]] = None ,
123+ app : Optional [App ] = None ,
124+ ** kwargs
82125 ) -> TaskQueue :
83126 """Creates a reference to a TaskQueue for a given function name.
84127
@@ -96,16 +139,31 @@ def task_queue(
96139
97140 Args:
98141 function_name: Name of the function.
99- extension_id: Firebase extension ID (optional).
142+ scope: FunctionScope configuration or Firebase extension ID (optional).
100143 app: An App instance (optional).
144+ **kwargs: Backwards-compatible parameters (e.g., extension_id).
101145
102146 Returns:
103147 TaskQueue: A TaskQueue instance.
104148
105149 Raises:
106150 ValueError: If the input arguments are invalid.
107151 """
108- return _get_functions_service (app ).task_queue (function_name , extension_id )
152+ extension_id = kwargs .pop ('extension_id' , None )
153+ if kwargs :
154+ raise TypeError (f"task_queue() got an unexpected keyword argument '{ next (iter (kwargs ))} '" )
155+
156+ if extension_id is not None :
157+ if scope is not None :
158+ raise ValueError ('Cannot set both scope and extension_id.' )
159+ warnings .warn (
160+ 'extension_id is deprecated. Use scope instead.' ,
161+ DeprecationWarning ,
162+ stacklevel = 2
163+ )
164+ scope = extension_id
165+
166+ return _get_functions_service (app ).task_queue (function_name , scope )
109167
110168class _FunctionsService :
111169 """Service class that implements Firebase Functions functionality."""
@@ -125,10 +183,14 @@ def __init__(self, app: App):
125183
126184 self ._http_client = _http_client .JsonHttpClient (credential = self ._credential )
127185
128- def task_queue (self , function_name : str , extension_id : Optional [str ] = None ) -> TaskQueue :
186+ def task_queue (
187+ self ,
188+ function_name : str ,
189+ scope : Optional [Union [str , FunctionScope ]] = None
190+ ) -> TaskQueue :
129191 """Creates a TaskQueue instance."""
130192 return TaskQueue (
131- function_name , extension_id , self ._project_id , self ._credential , self ._http_client ,
193+ function_name , scope , self ._project_id , self ._credential , self ._http_client ,
132194 self ._emulator_host )
133195
134196 @classmethod
@@ -142,7 +204,7 @@ class TaskQueue:
142204 def __init__ (
143205 self ,
144206 function_name : str ,
145- extension_id : Optional [str ],
207+ scope : Optional [Union [ str , FunctionScope ] ],
146208 project_id ,
147209 credential ,
148210 http_client ,
@@ -157,19 +219,78 @@ def __init__(
157219 self ._http_client = http_client
158220 self ._emulator_host = emulator_host
159221 self ._function_name = function_name
160- self ._extension_id = extension_id
222+
223+ # Determine the scope
224+ if scope is None :
225+ self ._scope = FunctionScope .current ()
226+ elif isinstance (scope , str ):
227+ _Validators .check_non_empty_string ('scope' , scope )
228+ self ._scope = FunctionScope ('extension_or_kit' , scope )
229+ elif isinstance (scope , FunctionScope ):
230+ self ._scope = scope
231+ else :
232+ raise ValueError ('scope must be a string or a FunctionScope object.' )
233+
161234 # Parse resources from function_name
162235 self ._resource = self ._parse_resource_name (self ._function_name , 'functions' )
163236
164237 # Apply defaults and validate resource_id
165238 self ._resource .project_id = self ._resource .project_id or self ._project_id
166239 self ._resource .location_id = self ._resource .location_id or _DEFAULT_LOCATION
167240 _Validators .check_non_empty_string ('resource.resource_id' , self ._resource .resource_id )
168- # Validate extension_id if provided and edit resources depending
169- if self ._extension_id is not None :
170- _Validators .check_non_empty_string ('extension_id' , self ._extension_id )
171- self ._resource .resource_id = f'ext-{ self ._extension_id } -{ self ._resource .resource_id } '
172241
242+ def _resolve_resource (self , scope : FunctionScope ) -> tuple [Resource , Optional [str ]]:
243+ """Resolves the Resource object and extension/kit ID based on the scope."""
244+ resource_id = self ._resource .resource_id
245+ extension_or_kit_id = None
246+
247+ if scope .type == 'current' :
248+ kit_instance_id = os .environ .get ('KIT_INSTANCE_ID' )
249+ if kit_instance_id :
250+ resource_id = f'kit-{ kit_instance_id } -{ resource_id } '
251+ extension_or_kit_id = kit_instance_id
252+ elif scope .type == 'global' :
253+ pass
254+ elif scope .type in ('extension' , 'extension_or_kit' ):
255+ resource_id = f'ext-{ scope .instance_id } -{ resource_id } '
256+ extension_or_kit_id = scope .instance_id
257+ elif scope .type == 'kit' :
258+ resource_id = f'kit-{ scope .instance_id } -{ resource_id } '
259+ extension_or_kit_id = scope .instance_id
260+
261+ resolved = Resource (
262+ resource_id = resource_id ,
263+ project_id = self ._resource .project_id ,
264+ location_id = self ._resource .location_id
265+ )
266+ return resolved , extension_or_kit_id
267+
268+ def _log_fallback_warning (self , function_name : str , instance : str ) -> None :
269+ """Logs a warning when falling back from extension to kit."""
270+ warnings .warn (
271+ f"Targeting kit { instance } with the legacy extensions API, "
272+ "which has performance implications. Please change the call "
273+ f"task_queue('{ function_name } ', '{ instance } ') to "
274+ f"task_queue('{ function_name } ', FunctionScope('kit', '{ instance } '))" ,
275+ UserWarning ,
276+ stacklevel = 3
277+ )
278+
279+ def _parse_enqueue_response (self , resp : dict , resolved_resource : Resource ) -> str :
280+ """Parses the enqueue response to extract the task ID."""
281+ if self ._is_emulated ():
282+ # Emulator returns a response with format {task: {name: <task_name>}}
283+ # The task name also has an extra '/' at the start compared to prod
284+ task_info = resp .get ('task' ) or {}
285+ task_name = task_info .get ('name' )
286+ if task_name :
287+ task_name = task_name [1 :]
288+ else :
289+ # Production returns a response with format {name: <task_name>}
290+ task_name = resp .get ('name' )
291+ task_resource = self ._parse_resource_name (
292+ task_name , f'queues/{ resolved_resource .resource_id } /tasks' )
293+ return task_resource .resource_id
173294
174295 def enqueue (self , task_data : Any , opts : Optional [TaskOptions ] = None ) -> str :
175296 """Creates a task and adds it to the queue. Tasks cannot be updated after creation.
@@ -188,32 +309,34 @@ def enqueue(self, task_data: Any, opts: Optional[TaskOptions] = None) -> str:
188309 Returns:
189310 str: The ID of the task relative to this queue.
190311 """
191- task = self ._validate_task_options (task_data , self ._resource , opts )
192- emulator_url = self ._get_emulator_url (self ._resource )
193- service_url = emulator_url or self ._get_url (self ._resource , _CLOUD_TASKS_API_URL_FORMAT )
194- task_payload = self ._update_task_payload (task , self ._resource , self ._extension_id )
312+ resolved_resource , ext_or_kit_id = self ._resolve_resource (self ._scope )
313+ task = self ._validate_task_options (task_data , resolved_resource , opts )
314+ service_url = self ._get_emulator_url (resolved_resource ) or self ._get_url (
315+ resolved_resource , _CLOUD_TASKS_API_URL_FORMAT )
316+ task_payload = self ._update_task_payload (task , resolved_resource , ext_or_kit_id )
195317 try :
196318 resp = self ._http_client .body (
197319 'post' ,
198320 url = service_url ,
199321 headers = _FUNCTIONS_HEADERS ,
200322 json = {'task' : task_payload .to_api_dict ()}
201323 )
202- if self ._is_emulated ():
203- # Emulator returns a response with format {task: {name: <task_name>}}
204- # The task name also has an extra '/' at the start compared to prod
205- task_info = resp .get ('task' ) or {}
206- task_name = task_info .get ('name' )
207- if task_name :
208- task_name = task_name [1 :]
209- else :
210- # Production returns a response with format {name: <task_name>}
211- task_name = resp .get ('name' )
212- task_resource = \
213- self ._parse_resource_name (task_name , f'queues/{ self ._resource .resource_id } /tasks' )
214- return task_resource .resource_id
324+ return self ._parse_enqueue_response (resp , resolved_resource )
215325 except requests .exceptions .RequestException as error :
216- raise _FunctionsService .handle_functions_error (error )
326+ is_404 = error .response is not None and error .response .status_code == 404
327+ if self ._scope .type != 'extension_or_kit' or not is_404 :
328+ raise _FunctionsService .handle_functions_error (error )
329+
330+ # Upgrade scope to kit and retry recursively, rolling back if it throws
331+ old_scope = self ._scope
332+ self ._scope = FunctionScope ('kit' , self ._scope .instance_id )
333+ try :
334+ resp = self .enqueue (task_data , opts )
335+ self ._log_fallback_warning (self ._function_name , self ._scope .instance_id )
336+ return resp
337+ except Exception :
338+ self ._scope = old_scope
339+ raise
217340
218341 def delete (self , task_id : str ) -> None :
219342 """Deletes an enqueued task if it has not yet started.
@@ -229,19 +352,34 @@ def delete(self, task_id: str) -> None:
229352 ValueError: If the input arguments are invalid.
230353 """
231354 _Validators .check_non_empty_string ('task_id' , task_id )
232- emulator_url = self ._get_emulator_url (self ._resource )
355+
356+ resolved_resource , _ = self ._resolve_resource (self ._scope )
357+ emulator_url = self ._get_emulator_url (resolved_resource )
233358 if emulator_url :
234359 service_url = emulator_url + f'/{ task_id } '
235360 else :
236- service_url = self ._get_url (self ._resource , _CLOUD_TASKS_API_URL_FORMAT + f'/{ task_id } ' )
361+ service_url = self ._get_url (
362+ resolved_resource , _CLOUD_TASKS_API_URL_FORMAT + f'/{ task_id } ' )
237363 try :
238364 self ._http_client .body (
239365 'delete' ,
240366 url = service_url ,
241367 headers = _FUNCTIONS_HEADERS ,
242368 )
243369 except requests .exceptions .RequestException as error :
244- raise _FunctionsService .handle_functions_error (error )
370+ is_404 = error .response is not None and error .response .status_code == 404
371+ if not is_404 :
372+ raise _FunctionsService .handle_functions_error (error )
373+
374+ if self ._scope .type == 'extension_or_kit' :
375+ old_scope = self ._scope
376+ self ._scope = FunctionScope ('kit' , self ._scope .instance_id )
377+ try :
378+ self .delete (task_id )
379+ self ._log_fallback_warning (self ._function_name , self ._scope .instance_id )
380+ except Exception :
381+ self ._scope = old_scope
382+ raise
245383
246384
247385 def _parse_resource_name (self , resource_name : str , resource_id_key : str ) -> Resource :
0 commit comments