Skip to content

Commit 92a5bc5

Browse files
authored
Merge branch 'dev' into fix/quote_url_part
2 parents 337adf9 + 8a93453 commit 92a5bc5

File tree

5 files changed

+230
-19
lines changed

5 files changed

+230
-19
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: 86 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
import json
2424
import datetime
2525
import inspect
26-
from typing import DefaultDict, List, Any, Dict, Optional, Tuple, Union
26+
from typing import DefaultDict, List, Any, Dict, Optional, Tuple, Union, Callable
2727
from uuid import UUID, uuid5, NAMESPACE_URL, NAMESPACE_OID
2828
from datetime import timezone
2929

@@ -35,6 +35,8 @@
3535
from .utils.entity_utils import EntityId
3636
from azure.functions._durable_functions import _deserialize_custom_object
3737
from azure.durable_functions.constants import DATETIME_STRING_FORMAT
38+
from azure.durable_functions.decorators.metadata import OrchestrationTrigger, ActivityTrigger
39+
from azure.functions.decorators.function_app import FunctionBuilder
3840

3941

4042
class DurableOrchestrationContext:
@@ -144,13 +146,14 @@ def _set_is_replaying(self, is_replaying: bool):
144146
"""
145147
self._is_replaying = is_replaying
146148

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

166180
def call_activity_with_retry(self,
167-
name: str, retry_options: RetryOptions,
181+
name: Union[str, Callable], retry_options: RetryOptions,
168182
input_: Optional[Any] = None) -> TaskBase:
169183
"""Schedule an activity for execution with retry options.
170184
171185
Parameters
172186
----------
173-
name: str
174-
The name of the activity function to call.
187+
name: str | Callable
188+
Either the name of the activity function to call, as a string or,
189+
in the Python V2 programming model, the activity function itself.
175190
retry_options: RetryOptions
176191
The retry options for the activity function.
177192
input_: Optional[Any]
@@ -183,6 +198,17 @@ def call_activity_with_retry(self,
183198
A Durable Task that completes when the called activity function completes or
184199
fails completely.
185200
"""
201+
if isinstance(name, Callable) and not isinstance(name, FunctionBuilder):
202+
error_message = "The `call_activity` API received a `Callable` without an "\
203+
"associated Azure Functions trigger-type. "\
204+
"Please ensure you're using the Python programming model V2 "\
205+
"and that your activity function is annotated with the `activity_trigger`"\
206+
"decorator. Otherwise, provide in the name of the activity as a string."
207+
raise ValueError(error_message)
208+
209+
if isinstance(name, FunctionBuilder):
210+
name = self._get_function_name(name, ActivityTrigger)
211+
186212
action = CallActivityWithRetryAction(name, retry_options, input_)
187213
task = self._generate_task(action, retry_options)
188214
return task
@@ -222,13 +248,13 @@ def call_http(self, method: str, uri: str, content: Optional[str] = None,
222248
return task
223249

224250
def call_sub_orchestrator(self,
225-
name: str, input_: Optional[Any] = None,
251+
name: Union[str, Callable], input_: Optional[Any] = None,
226252
instance_id: Optional[str] = None) -> TaskBase:
227253
"""Schedule sub-orchestration function named `name` for execution.
228254
229255
Parameters
230256
----------
231-
name: str
257+
name: Union[str, Callable]
232258
The name of the orchestrator function to call.
233259
input_: Optional[Any]
234260
The JSON-serializable input to pass to the orchestrator function.
@@ -240,19 +266,30 @@ def call_sub_orchestrator(self,
240266
Task
241267
A Durable Task that completes when the called sub-orchestrator completes or fails.
242268
"""
269+
if isinstance(name, Callable) and not isinstance(name, FunctionBuilder):
270+
error_message = "The `call_activity` API received a `Callable` without an "\
271+
"associated Azure Functions trigger-type. "\
272+
"Please ensure you're using the Python programming model V2 "\
273+
"and that your activity function is annotated with the `activity_trigger`"\
274+
"decorator. Otherwise, provide in the name of the activity as a string."
275+
raise ValueError(error_message)
276+
277+
if isinstance(name, FunctionBuilder):
278+
name = self._get_function_name(name, OrchestrationTrigger)
279+
243280
action = CallSubOrchestratorAction(name, input_, instance_id)
244281
task = self._generate_task(action)
245282
return task
246283

247284
def call_sub_orchestrator_with_retry(self,
248-
name: str, retry_options: RetryOptions,
285+
name: Union[str, Callable], retry_options: RetryOptions,
249286
input_: Optional[Any] = None,
250287
instance_id: Optional[str] = None) -> TaskBase:
251288
"""Schedule sub-orchestration function named `name` for execution, with retry-options.
252289
253290
Parameters
254291
----------
255-
name: str
292+
name: Union[str, Callable]
256293
The name of the activity function to schedule.
257294
retry_options: RetryOptions
258295
The settings for retrying this sub-orchestrator in case of a failure.
@@ -266,6 +303,17 @@ def call_sub_orchestrator_with_retry(self,
266303
Task
267304
A Durable Task that completes when the called sub-orchestrator completes or fails.
268305
"""
306+
if isinstance(name, Callable) and not isinstance(name, FunctionBuilder):
307+
error_message = "The `call_activity` API received a `Callable` without an "\
308+
"associated Azure Functions trigger-type. "\
309+
"Please ensure you're using the Python programming model V2 "\
310+
"and that your activity function is annotated with the `activity_trigger`"\
311+
"decorator. Otherwise, provide in the name of the activity as a string."
312+
raise ValueError(error_message)
313+
314+
if isinstance(name, FunctionBuilder):
315+
name = self._get_function_name(name, OrchestrationTrigger)
316+
269317
action = CallSubOrchestratorWithRetryAction(name, retry_options, input_, instance_id)
270318
task = self._generate_task(action, retry_options)
271319
return task
@@ -628,3 +676,30 @@ def _add_to_open_tasks(self, task: TaskBase):
628676
else:
629677
for child in task.children:
630678
self._add_to_open_tasks(child)
679+
680+
def _get_function_name(self, name: FunctionBuilder,
681+
trigger_type: Union[OrchestrationTrigger, ActivityTrigger]):
682+
try:
683+
if (isinstance(name._function._trigger, trigger_type)):
684+
name = name._function._name
685+
return name
686+
else:
687+
if(trigger_type == OrchestrationTrigger):
688+
trigger_type = "OrchestrationTrigger"
689+
else:
690+
trigger_type = "ActivityTrigger"
691+
error_message = "Received function with Trigger-type `"\
692+
+ name._function._trigger.type\
693+
+ "` but expected `" + trigger_type + "`. Ensure your "\
694+
"function is annotated with the `" + trigger_type +\
695+
"` decorator or directly pass in the name of the "\
696+
"function as a string."
697+
raise ValueError(error_message)
698+
except AttributeError as e:
699+
e.message = "Durable Functions SDK internal error: an "\
700+
"expected attribute is missing from the `FunctionBuilder` "\
701+
"object in the Python V2 programming model. Please report "\
702+
"this bug in the Durable Functions Python SDK repo: "\
703+
"https://github.com/Azure/azure-functions-durable-python.\n"\
704+
"Error trace: " + e.message
705+
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)

tests/orchestrator/test_sub_orchestrator.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55
from azure.durable_functions.models.OrchestratorState import OrchestratorState
66
from azure.durable_functions.models.actions.CallSubOrchestratorAction \
77
import CallSubOrchestratorAction
8+
import azure.durable_functions as df
9+
import azure.functions as func
810

11+
app = df.DFApp(http_auth_level=func.AuthLevel.ANONYMOUS)
912

1013
def generator_function(context):
1114
outputs = []
@@ -19,6 +22,22 @@ def generator_function(context):
1922

2023
return outputs
2124

25+
def generator_function_call_by_function_name(context):
26+
outputs = []
27+
task1 = yield context.call_sub_orchestrator(HelloSubOrchestrator, "Tokyo")
28+
task2 = yield context.call_sub_orchestrator(HelloSubOrchestrator, "Seattle")
29+
task3 = yield context.call_sub_orchestrator(HelloSubOrchestrator, "London")
30+
31+
outputs.append(task1)
32+
outputs.append(task2)
33+
outputs.append(task3)
34+
35+
return outputs
36+
37+
@app.orchestration_trigger(context_name="context")
38+
def HelloSubOrchestrator(context):
39+
return "Hello" + context
40+
2241
def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState:
2342
return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema.value)
2443

@@ -54,3 +73,23 @@ def test_tokyo_and_seattle_and_london_state():
5473

5574
#assert_valid_schema(result)
5675
assert_orchestration_state_equals(expected, result)
76+
77+
def test_call_suborchestrator_by_name():
78+
context_builder = ContextBuilder('test_call_suborchestrator_by_name')
79+
add_hello_suborch_completed_events(context_builder, 0, "\"Hello Tokyo!\"")
80+
add_hello_suborch_completed_events(context_builder, 1, "\"Hello Seattle!\"")
81+
add_hello_suborch_completed_events(context_builder, 2, "\"Hello London!\"")
82+
83+
result = get_orchestration_state_result(
84+
context_builder, generator_function_call_by_function_name)
85+
86+
expected_state = base_expected_state(
87+
['Hello Tokyo!', 'Hello Seattle!', 'Hello London!'])
88+
add_hello_suborch_action(expected_state, 'Tokyo')
89+
add_hello_suborch_action(expected_state, 'Seattle')
90+
add_hello_suborch_action(expected_state, 'London')
91+
expected_state._is_done = True
92+
expected = expected_state.to_json()
93+
94+
#assert_valid_schema(result)
95+
assert_orchestration_state_equals(expected, result)

0 commit comments

Comments
 (0)