Skip to content

Commit 1105d8c

Browse files
authored
Merge pull request #440 from Azure/dev
2 parents f0fa726 + c529728 commit 1105d8c

File tree

7 files changed

+238
-23
lines changed

7 files changed

+238
-23
lines changed

azure/durable_functions/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,8 @@ 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')
79-
__all__.append('BluePrint')
79+
__all__.append('Blueprint')
8080
except ModuleNotFoundError:
8181
pass
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/decorators/durable_app.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@
1111
from functools import wraps
1212

1313

14-
class BluePrint(TriggerApi, BindingApi):
15-
"""Durable Functions (DF) blueprint container.
14+
class Blueprint(TriggerApi, BindingApi):
15+
"""Durable Functions (DF) Blueprint container.
1616
1717
It allows functions to be declared via trigger and binding decorators,
1818
but does not automatically index/register these functions.
@@ -232,7 +232,7 @@ def decorator():
232232
return wrap
233233

234234

235-
class DFApp(BluePrint, FunctionRegister):
235+
class DFApp(Blueprint, FunctionRegister):
236236
"""Durable Functions (DF) app.
237237
238238
Exports the decorators required to declare and index DF Function-types.

azure/durable_functions/models/DurableOrchestrationContext.py

Lines changed: 86 additions & 11 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
@@ -221,13 +247,13 @@ def call_http(self, method: str, uri: str, content: Optional[str] = None,
221247
return task
222248

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

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

setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ def run(self, *args, **kwargs):
3434
]),
3535
use_scm_version=True,
3636
setup_requires=['setuptools_scm'],
37+
author="Azure Functions team at Microsoft Corp.",
38+
author_email="[email protected]",
39+
keywords="azure functions azurefunctions python serverless workflows durablefunctions",
40+
url="https://github.com/Azure/azure-functions-durable-python",
3741
description='Durable Functions For Python',
3842
long_description=long_description,
3943
long_description_content_type="text/markdown",

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)