diff --git a/azure/functions/decorators/constants.py b/azure/functions/decorators/constants.py index 12d8e5ef..ce439e64 100644 --- a/azure/functions/decorators/constants.py +++ b/azure/functions/decorators/constants.py @@ -30,3 +30,7 @@ DAPR_INVOKE = "daprInvoke" DAPR_PUBLISH = "daprPublish" DAPR_BINDING = "daprBinding" +ORCHESTRATION_TRIGGER = "orchestrationTrigger" +ACTIVITY_TRIGGER = "activityTrigger" +ENTITY_TRIGGER = "entityTrigger" +DURABLE_CLIENT = "durableClient" diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 6166ae65..172e2b05 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -277,6 +277,42 @@ def __init__(self, *args, **kwargs): self._function_builders: List[FunctionBuilder] = [] self._app_script_file: str = SCRIPT_FILE_NAME + def _invoke_df_decorator(self, df_decorator): + """ + Invoke a Durable Functions decorator from the DF SDK, and store the + resulting :class:`FunctionBuilder` object within the `DecoratorApi`. + + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + function_builder = df_decorator(fb._function._func) + + # remove old function builder from `self` and replace + # it with the result of the DF decorator + self._function_builders.pop() + self._function_builders.append(function_builder) + return function_builder + return decorator() + return wrap + + def _get_durable_blueprint(self): + """Attempt to import the Durable Functions SDK from which DF decorators are + implemented. + """ + + try: + import azure.durable_functions as df + df_bp = df.Blueprint() + return df_bp + except ImportError: + error_message = "Attempted to use a Durable Functions decorator, "\ + "but the `azure-functions-durable` SDK package could not be "\ + "found. Please install `azure-functions-durable` to use "\ + "Durable Functions." + raise Exception(error_message) + @property def app_script_file(self) -> str: """Name of function app script file in which all the functions @@ -443,6 +479,59 @@ def decorator(): return wrap + def orchestration_trigger(self, context_name: str, + orchestration: Optional[str] = None): + """Register an Orchestrator Function. + + Parameters + ---------- + context_name: str + Parameter name of the DurableOrchestrationContext object. + orchestration: Optional[str] + Name of Orchestrator Function. + By default, the name of the method is used. + """ + df_bp = self._get_durable_blueprint() + df_decorator = df_bp.orchestration_trigger(context_name, + orchestration) + result = self._invoke_df_decorator(df_decorator) + return result + + def entity_trigger(self, context_name: str, + entity_name: Optional[str] = None): + """Register an Entity Function. + + Parameters + ---------- + context_name: str + Parameter name of the Entity input. + entity_name: Optional[str] + Name of Entity Function. + """ + + df_bp = self._get_durable_blueprint() + df_decorator = df_bp.entity_trigger(context_name, + entity_name) + result = self._invoke_df_decorator(df_decorator) + return result + + def activity_trigger(self, input_name: str, + activity: Optional[str] = None): + """Register an Activity Function. + + Parameters + ---------- + input_name: str + Parameter name of the Activity input. + activity: Optional[str] + Name of Activity Function. + """ + + df_bp = self._get_durable_blueprint() + df_decorator = df_bp.activity_trigger(input_name, activity) + result = self._invoke_df_decorator(df_decorator) + return result + def timer_trigger(self, arg_name: str, schedule: str, @@ -1350,6 +1439,37 @@ def decorator(): class BindingApi(DecoratorApi, ABC): """Interface to extend for using existing binding decorator functions.""" + def durable_client_input(self, + client_name: str, + task_hub: Optional[str] = None, + connection_name: Optional[str] = None + ): + """Register a Durable-client Function. + + Parameters + ---------- + client_name: str + Parameter name of durable client. + task_hub: Optional[str] + Used in scenarios where multiple function apps share the + same storage account but need to be isolated from each other. + If not specified, the default value from host.json is used. + This value must match the value used by the target + orchestrator functions. + connection_name: Optional[str] + The name of an app setting that contains a storage account + connection string. The storage account represented by this + connection string must be the same one used by the target + orchestrator functions. If not specified, the default storage + account connection string for the function app is used. + """ + df_bp = self._get_durable_blueprint() + df_decorator = df_bp.durable_client_input(client_name, + task_hub, + connection_name) + result = self._invoke_df_decorator(df_decorator) + return result + def service_bus_queue_output(self, arg_name: str, connection: str, diff --git a/setup.py b/setup.py index 69c8e2d0..704dc48a 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,8 @@ 'pytest', 'pytest-cov', 'requests==2.*', - 'coverage' + 'coverage', + 'azure-functions-durable' ] } diff --git a/tests/decorators/test_decorators.py b/tests/decorators/test_decorators.py index 97c50a7a..f31a42bc 100644 --- a/tests/decorators/test_decorators.py +++ b/tests/decorators/test_decorators.py @@ -6,7 +6,8 @@ HTTP_OUTPUT, QUEUE, QUEUE_TRIGGER, SERVICE_BUS, SERVICE_BUS_TRIGGER, \ EVENT_HUB, EVENT_HUB_TRIGGER, COSMOS_DB, COSMOS_DB_TRIGGER, BLOB, \ BLOB_TRIGGER, EVENT_GRID_TRIGGER, EVENT_GRID, TABLE, WARMUP_TRIGGER, \ - SQL, SQL_TRIGGER + SQL, SQL_TRIGGER, ORCHESTRATION_TRIGGER, ACTIVITY_TRIGGER, \ + ENTITY_TRIGGER, DURABLE_CLIENT from azure.functions.decorators.core import DataType, AuthLevel, \ BindingDirection, AccessRights, Cardinality from azure.functions.decorators.function_app import FunctionApp @@ -160,6 +161,84 @@ def dummy(): ] }) + def test_orchestration_trigger(self): + app = self.func_app + + @app.orchestration_trigger("context") + def dummy1(context): + pass + + func = self._get_user_function(app) + assert_json(self, func, { + "scriptFile": "function_app.py", + "bindings": [ + { + "name": "context", + "type": ORCHESTRATION_TRIGGER, + "direction": BindingDirection.IN + } + ] + }) + + def test_activity_trigger(self): + app = self.func_app + + @app.activity_trigger("arg") + def dummy2(arg): + pass + + func = self._get_user_function(app) + assert_json(self, func, { + "scriptFile": "function_app.py", + "bindings": [ + { + "name": "arg", + "type": ACTIVITY_TRIGGER, + "direction": BindingDirection.IN + } + ] + }) + + def test_entity_trigger(self): + app = self.func_app + + @app.entity_trigger("context") + def dummy3(context): + pass + + func = self._get_user_function(app) + assert_json(self, func, { + "scriptFile": "function_app.py", + "bindings": [ + { + "name": "context", + "type": ENTITY_TRIGGER, + "direction": BindingDirection.IN, + } + ] + }) + + def test_durable_client(self): + app = self.func_app + + @app.generic_trigger(arg_name="req", type=HTTP_TRIGGER) + @app.durable_client_input(client_name="client") + def dummy(client): + pass + + func = self._get_user_function(app) + + self.assertEqual(len(func.get_bindings()), 2) + self.assertTrue(func.is_http_function()) + + output = func.get_bindings()[0] + + self.assertEqual(output.get_dict_repr(), { + "direction": BindingDirection.IN, + "type": DURABLE_CLIENT, + "name": "client" + }) + def test_route_default_args(self): app = self.func_app