Skip to content

Commit 021e9ee

Browse files
nytiandavidmrdavid
andauthored
Call Activity Function by function name (#424)
* call activity by name * add whitespace * update * Update DurableOrchestrationContext.py * remove whitespace * edit word * update exception * update arg * update activity * update test * update test * test * test * add test * fix test * add test * update * update test * update * update * change error msg * update test * update typo * Update DurableOrchestrationContext.py * update * Update DurableOrchestrationContext.py * Update DurableOrchestrationContext.py * Update __init__.py * Update DurableOrchestrationContext.py * reword error & update decorators init.py * fix error * add new empty line * reorgnize * update error message * update indentation * update indentation * update init * Update __init__.py * update test * reorganize code * remove whitespace * update get_function_name method * update get_function_name method * update get_function_name method * change from | to union * Update DurableOrchestrationContext.py * Update DurableOrchestrationContext.py * Update DurableOrchestrationContext.py --------- Co-authored-by: David Justo <[email protected]>
1 parent 00e3a34 commit 021e9ee

File tree

4 files changed

+165
-15
lines changed

4 files changed

+165
-15
lines changed

azure/durable_functions/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ def validate_extension_bundles():
7474

7575
try:
7676
# disabling linter on this line because it fails to recognize the conditional export
77-
from .decorators import DFApp, BluePrint # noqa
77+
from .decorators.durable_app import (DFApp, BluePrint) # noqa
7878
__all__.append('DFApp')
7979
__all__.append('BluePrint')
8080
except ModuleNotFoundError:
Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,3 @@
11
# Copyright (c) Microsoft Corporation. All rights reserved.
22
# Licensed under the MIT License.
33
"""Decorator definitions for Durable Functions."""
4-
from .durable_app import DFApp, BluePrint
5-
6-
__all__ = [
7-
"DFApp",
8-
"BluePrint"
9-
]

azure/durable_functions/models/DurableOrchestrationContext.py

Lines changed: 60 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
import json
2323
import datetime
2424
import inspect
25-
from typing import DefaultDict, List, Any, Dict, Optional, Tuple, Union
25+
from typing import DefaultDict, List, Any, Dict, Optional, Tuple, Union, Callable
2626
from uuid import UUID, uuid5, NAMESPACE_URL, NAMESPACE_OID
2727
from datetime import timezone
2828

@@ -34,6 +34,8 @@
3434
from .utils.entity_utils import EntityId
3535
from azure.functions._durable_functions import _deserialize_custom_object
3636
from azure.durable_functions.constants import DATETIME_STRING_FORMAT
37+
from azure.durable_functions.decorators.metadata import OrchestrationTrigger, ActivityTrigger
38+
from azure.functions.decorators.function_app import FunctionBuilder
3739

3840

3941
class DurableOrchestrationContext:
@@ -143,13 +145,14 @@ def _set_is_replaying(self, is_replaying: bool):
143145
"""
144146
self._is_replaying = is_replaying
145147

146-
def call_activity(self, name: str, input_: Optional[Any] = None) -> TaskBase:
148+
def call_activity(self, name: Union[str, Callable], input_: Optional[Any] = None) -> TaskBase:
147149
"""Schedule an activity for execution.
148150
149151
Parameters
150152
----------
151-
name: str
152-
The name of the activity function to call.
153+
name: str | Callable
154+
Either the name of the activity function to call, as a string or,
155+
in the Python V2 programming model, the activity function itself.
153156
input_: Optional[Any]
154157
The JSON-serializable input to pass to the activity function.
155158
@@ -158,19 +161,31 @@ def call_activity(self, name: str, input_: Optional[Any] = None) -> TaskBase:
158161
Task
159162
A Durable Task that completes when the called activity function completes or fails.
160163
"""
164+
if isinstance(name, Callable) and not isinstance(name, FunctionBuilder):
165+
error_message = "The `call_activity` API received a `Callable` without an "\
166+
"associated Azure Functions trigger-type. "\
167+
"Please ensure you're using the Python programming model V2 "\
168+
"and that your activity function is annotated with the `activity_trigger`"\
169+
"decorator. Otherwise, provide in the name of the activity as a string."
170+
raise ValueError(error_message)
171+
172+
if isinstance(name, FunctionBuilder):
173+
name = self._get_function_name(name, ActivityTrigger)
174+
161175
action = CallActivityAction(name, input_)
162176
task = self._generate_task(action)
163177
return task
164178

165179
def call_activity_with_retry(self,
166-
name: str, retry_options: RetryOptions,
180+
name: Union[str, Callable], retry_options: RetryOptions,
167181
input_: Optional[Any] = None) -> TaskBase:
168182
"""Schedule an activity for execution with retry options.
169183
170184
Parameters
171185
----------
172-
name: str
173-
The name of the activity function to call.
186+
name: str | Callable
187+
Either the name of the activity function to call, as a string or,
188+
in the Python V2 programming model, the activity function itself.
174189
retry_options: RetryOptions
175190
The retry options for the activity function.
176191
input_: Optional[Any]
@@ -182,6 +197,17 @@ def call_activity_with_retry(self,
182197
A Durable Task that completes when the called activity function completes or
183198
fails completely.
184199
"""
200+
if isinstance(name, Callable) and not isinstance(name, FunctionBuilder):
201+
error_message = "The `call_activity` API received a `Callable` without an "\
202+
"associated Azure Functions trigger-type. "\
203+
"Please ensure you're using the Python programming model V2 "\
204+
"and that your activity function is annotated with the `activity_trigger`"\
205+
"decorator. Otherwise, provide in the name of the activity as a string."
206+
raise ValueError(error_message)
207+
208+
if isinstance(name, FunctionBuilder):
209+
name = self._get_function_name(name, ActivityTrigger)
210+
185211
action = CallActivityWithRetryAction(name, retry_options, input_)
186212
task = self._generate_task(action, retry_options)
187213
return task
@@ -627,3 +653,30 @@ def _add_to_open_tasks(self, task: TaskBase):
627653
else:
628654
for child in task.children:
629655
self._add_to_open_tasks(child)
656+
657+
def _get_function_name(self, name: FunctionBuilder,
658+
trigger_type: Union[OrchestrationTrigger, ActivityTrigger]):
659+
try:
660+
if (isinstance(name._function._trigger, trigger_type)):
661+
name = name._function._name
662+
return name
663+
else:
664+
if(trigger_type == OrchestrationTrigger):
665+
trigger_type = "OrchestrationTrigger"
666+
else:
667+
trigger_type = "ActivityTrigger"
668+
error_message = "Received function with Trigger-type `"\
669+
+ name._function._trigger.type\
670+
+ "` but expected `" + trigger_type + "`. Ensure your "\
671+
"function is annotated with the `" + trigger_type +\
672+
"` decorator or directly pass in the name of the "\
673+
"function as a string."
674+
raise ValueError(error_message)
675+
except AttributeError as e:
676+
e.message = "Durable Functions SDK internal error: an "\
677+
"expected attribute is missing from the `FunctionBuilder` "\
678+
"object in the Python V2 programming model. Please report "\
679+
"this bug in the Durable Functions Python SDK repo: "\
680+
"https://github.com/Azure/azure-functions-durable-python.\n"\
681+
"Error trace: " + e.message
682+
raise e

tests/orchestrator/test_sequential_orchestrator.py

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,42 @@ def generator_function_new_guid(context):
187187
outputs.append(str(output3))
188188
return outputs
189189

190+
def generator_function_call_activity_with_name(context):
191+
"""Simple orchestrator that call activity function with function name"""
192+
outputs = []
193+
194+
task1 = yield context.call_activity(Hello, "Tokyo")
195+
task2 = yield context.call_activity(Hello, "Seattle")
196+
task3 = yield context.call_activity(Hello, "London")
197+
198+
outputs.append(task1)
199+
outputs.append(task2)
200+
outputs.append(task3)
201+
202+
return outputs
203+
204+
def generator_function_call_activity_with_callable(context):
205+
outputs = []
206+
207+
task1 = yield context.call_activity(generator_function, "Tokyo")
208+
209+
outputs.append(task1)
210+
211+
return outputs
212+
213+
def generator_function_call_activity_with_orchestrator(context):
214+
outputs = []
215+
216+
task1 = yield context.call_activity(generator_function_rasing_ex_with_pystein, "Tokyo")
217+
218+
outputs.append(task1)
219+
220+
return outputs
221+
222+
@app.activity_trigger(input_name = "myArg")
223+
def Hello(myArg: str):
224+
return "Hello" + myArg
225+
190226
def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState:
191227
return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema)
192228

@@ -272,6 +308,73 @@ def test_failed_tokyo_state():
272308
expected_error_str = f"{error_msg}{error_label}{state_str}"
273309
assert expected_error_str == error_str
274310

311+
def test_call_activity_with_name():
312+
context_builder = ContextBuilder('test_call_activity_with_name')
313+
add_hello_completed_events(context_builder, 0, "\"Hello Tokyo!\"")
314+
add_hello_completed_events(context_builder, 1, "\"Hello Seattle!\"")
315+
add_hello_completed_events(context_builder, 2, "\"Hello London!\"")
316+
result = get_orchestration_state_result(
317+
context_builder, generator_function_call_activity_with_name)
318+
319+
expected_state = base_expected_state(
320+
['Hello Tokyo!', 'Hello Seattle!', 'Hello London!'])
321+
add_hello_action(expected_state, 'Tokyo')
322+
add_hello_action(expected_state, 'Seattle')
323+
add_hello_action(expected_state, 'London')
324+
expected_state._is_done = True
325+
expected = expected_state.to_json()
326+
327+
assert_valid_schema(result)
328+
assert_orchestration_state_equals(expected, result)
329+
330+
def test_call_activity_function_callable_exception():
331+
context_builder = ContextBuilder('test_call_activity_by_name_exception')
332+
333+
try:
334+
result = get_orchestration_state_result(
335+
context_builder, generator_function_call_activity_with_callable)
336+
# expected an exception
337+
assert False
338+
except Exception as e:
339+
error_label = "\n\n$OutOfProcData$:"
340+
error_str = str(e)
341+
342+
expected_state = base_expected_state()
343+
error_msg = "The `call_activity` API received a `Callable` without an "\
344+
"associated Azure Functions trigger-type. "\
345+
"Please ensure you're using the Python programming model V2 "\
346+
"and that your activity function is annotated with the `activity_trigger`"\
347+
"decorator. Otherwise, provide in the name of the activity as a string."
348+
expected_state._error = error_msg
349+
state_str = expected_state.to_json_string()
350+
351+
expected_error_str = f"{error_msg}{error_label}{state_str}"
352+
assert expected_error_str == error_str
353+
354+
def test_call_activity_function_with_orchestrator_exception():
355+
context_builder = ContextBuilder('test_call_activity_by_name_exception')
356+
357+
try:
358+
result = get_orchestration_state_result(
359+
context_builder, generator_function_call_activity_with_orchestrator)
360+
# expected an exception
361+
assert False
362+
except Exception as e:
363+
error_label = "\n\n$OutOfProcData$:"
364+
error_str = str(e)
365+
366+
expected_state = base_expected_state()
367+
error_msg = "Received function with Trigger-type `"\
368+
+ generator_function_rasing_ex_with_pystein._function._trigger.type\
369+
+ "` but expected `ActivityTrigger`. Ensure your "\
370+
"function is annotated with the `ActivityTrigger`" \
371+
" decorator or directly pass in the name of the "\
372+
"function as a string."
373+
expected_state._error = error_msg
374+
state_str = expected_state.to_json_string()
375+
376+
expected_error_str = f"{error_msg}{error_label}{state_str}"
377+
assert expected_error_str == error_str
275378

276379
def test_user_code_raises_exception():
277380
context_builder = ContextBuilder('test_simple_function')
@@ -608,4 +711,4 @@ def test_compound_tasks_return_single_action_in_V2():
608711
expected = expected_state.to_json()
609712

610713
#assert_valid_schema(result)
611-
assert_orchestration_state_equals(expected, result)
714+
assert_orchestration_state_equals(expected, result)

0 commit comments

Comments
 (0)