Skip to content

Commit 81b67f5

Browse files
authored
Expose OrchestratorGeneratorWrapper in SDK (#548)
* Expose OrchestratorGeneratorWrapper in SDK
1 parent 0cb7871 commit 81b67f5

File tree

7 files changed

+106
-93
lines changed

7 files changed

+106
-93
lines changed

azure-functions-durable-python.sln

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
Microsoft Visual Studio Solution File, Format Version 12.00
2+
# Visual Studio Version 17
3+
VisualStudioVersion = 17.5.2.0
4+
MinimumVisualStudioVersion = 10.0.40219.1
5+
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}"
6+
EndProject
7+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "extensions", "samples\aml_monitoring\extensions.csproj", "{33E598B8-4178-679F-9B92-BE8D8A64F1A5}"
8+
EndProject
9+
Global
10+
GlobalSection(SolutionConfigurationPlatforms) = preSolution
11+
Debug|Any CPU = Debug|Any CPU
12+
Release|Any CPU = Release|Any CPU
13+
EndGlobalSection
14+
GlobalSection(ProjectConfigurationPlatforms) = postSolution
15+
{33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
16+
{33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
17+
{33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
18+
{33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Release|Any CPU.Build.0 = Release|Any CPU
19+
EndGlobalSection
20+
GlobalSection(SolutionProperties) = preSolution
21+
HideSolutionNode = FALSE
22+
EndGlobalSection
23+
GlobalSection(NestedProjects) = preSolution
24+
{33E598B8-4178-679F-9B92-BE8D8A64F1A5} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA}
25+
EndGlobalSection
26+
GlobalSection(ExtensibilityGlobals) = postSolution
27+
SolutionGuid = {AEA3AC93-4361-47CD-A8C7-CA280ABE1BDC}
28+
EndGlobalSection
29+
EndGlobal

azure/durable_functions/models/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from .DurableHttpRequest import DurableHttpRequest
1010
from .TokenSource import ManagedIdentityTokenSource
1111
from .DurableEntityContext import DurableEntityContext
12+
from .Task import TaskBase
1213

1314
__all__ = [
1415
'DurableOrchestrationBindings',
@@ -20,5 +21,6 @@
2021
'OrchestratorState',
2122
'OrchestrationRuntimeStatus',
2223
'PurgeHistoryResult',
23-
'RetryOptions'
24+
'RetryOptions',
25+
'TaskBase'
2426
]
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from typing import Generator, Any, Union
2+
3+
from azure.durable_functions.models import TaskBase
4+
5+
def orchestrator_generator_wrapper(generator: Generator[TaskBase, Any, Any]) -> Generator[Union[TaskBase, Any], None, None]:
6+
"""Wraps a user-defined orchestrator function to simulate the Durable replay logic.
7+
8+
Parameters
9+
----------
10+
generator: Generator[TaskBase, Any, Any]
11+
Generator orchestrator as defined in the user function app. This generator is expected
12+
to yield a series of TaskBase objects and receive the results of these tasks until
13+
returning the result of the orchestrator.
14+
15+
Returns
16+
-------
17+
Generator[Union[TaskBase, Any], None, None]
18+
A simplified version of the orchestrator which takes no inputs. This generator will
19+
yield back the TaskBase objects that are yielded from the user orchestrator as well
20+
as the final result of the orchestrator. Exception handling is also simulated here
21+
in the same way as replay, where tasks returning exceptions are thrown back into the
22+
orchestrator.
23+
"""
24+
previous = next(generator)
25+
yield previous
26+
while True:
27+
try:
28+
previous_result = None
29+
try:
30+
previous_result = previous.result
31+
except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw.
32+
previous = generator.throw(e)
33+
else:
34+
previous = generator.send(previous_result)
35+
yield previous
36+
except StopIteration as e:
37+
yield e.value
38+
return
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Unit testing utilities for Azure Durable functions."""
2+
from .OrchestratorGeneratorWrapper import orchestrator_generator_wrapper
3+
4+
__all__ = [
5+
'orchestrator_generator_wrapper'
6+
]

samples-v2/blueprint/tests/test_my_orchestrator.py

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,29 @@
1-
from datetime import timedelta
21
import unittest
32
from unittest.mock import Mock, call, patch
3+
from azure.durable_functions.testing import orchestrator_generator_wrapper
44

55
from durable_blueprints import my_orchestrator
66

7-
# A way to wrap an orchestrator generator to simplify calling it and getting the results.
8-
# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call,
9-
# we can unwrap the orchestrator generator using this method to simplify per-test code.
10-
def orchestrator_generator_wrapper(generator):
11-
previous = next(generator)
12-
yield previous
13-
while True:
14-
try:
15-
previous_result = None
16-
try:
17-
previous_result = previous.result
18-
except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw.
19-
previous = generator.throw(e)
20-
else:
21-
previous = generator.send(previous_result)
22-
yield previous
23-
except StopIteration as e:
24-
yield e.value
25-
return
26-
27-
28-
class MockTask():
29-
def __init__(self, result=None):
30-
self.result = result
31-
32-
33-
def mock_activity(activity_name, input):
7+
@patch('azure.durable_functions.models.TaskBase')
8+
def mock_activity(activity_name, input, task):
349
if activity_name == "say_hello":
35-
return MockTask(f"Hello {input}!")
10+
task.result = f"Hello {input}!"
11+
return task
3612
raise Exception("Activity not found")
3713

3814

3915
class TestFunction(unittest.TestCase):
4016
@patch('azure.durable_functions.DurableOrchestrationContext')
41-
def test_chaining_orchestrator(self, context):
17+
def test_my_orchestrator(self, context):
4218
# Get the original method definition as seen in the function_app.py file
4319
func_call = my_orchestrator.build().get_user_function().orchestrator_function
4420

4521
context.call_activity = Mock(side_effect=mock_activity)
4622
# Create a generator using the method and mocked context
4723
user_orchestrator = func_call(context)
4824

49-
# Use a method defined above to get the values from the generator. Quick unwrap for easy access
25+
# Use orchestrator_generator_wrapper to get the values from the generator.
26+
# Processes the orchestrator in a way that is equivalent to the Durable replay logic
5027
values = [val for val in orchestrator_generator_wrapper(user_orchestrator)]
5128

5229
expected_activity_calls = [call('say_hello', 'Tokyo'),

samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py

Lines changed: 14 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,20 @@
11
import unittest
22
from unittest.mock import Mock, call, patch
3+
from azure.durable_functions.testing import orchestrator_generator_wrapper
34

45
from function_app import E2_BackupSiteContent
56

6-
# A way to wrap an orchestrator generator to simplify calling it and getting the results.
7-
# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call,
8-
# we can unwrap the orchestrator generator using this method to simplify per-test code.
9-
def orchestrator_generator_wrapper(generator):
10-
previous = next(generator)
11-
yield previous
12-
while True:
13-
try:
14-
previous_result = None
15-
try:
16-
previous_result = previous.result
17-
except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw.
18-
previous = generator.throw(e)
19-
else:
20-
previous = generator.send(previous_result)
21-
yield previous
22-
except StopIteration as e:
23-
yield e.value
24-
return
25-
26-
27-
class MockTask():
28-
def __init__(self, result=None):
29-
self.result = result
30-
31-
32-
def mock_activity(activity_name, input):
7+
8+
@patch('azure.durable_functions.models.TaskBase')
9+
def create_mock_task(result, task):
10+
task.result = result
11+
return task
12+
13+
14+
def mock_activity(activity_name, input, task):
3315
if activity_name == "E2_GetFileList":
34-
return MockTask(["C:/test/E2_Activity.py", "C:/test/E2_Orchestrator.py"])
35-
return MockTask(input)
16+
return create_mock_task(["C:/test/E2_Activity.py", "C:/test/E2_Orchestrator.py"])
17+
raise Exception("Activity not found")
3618

3719

3820
class TestFunction(unittest.TestCase):
@@ -43,12 +25,13 @@ def test_E2_BackupSiteContent(self, context):
4325

4426
context.get_input = Mock(return_value="C:/test")
4527
context.call_activity = Mock(side_effect=mock_activity)
46-
context.task_all = Mock(return_value=MockTask([100, 200, 300]))
28+
context.task_all = Mock(return_value=create_mock_task([100, 200, 300]))
4729

4830
# Execute the function code
4931
user_orchestrator = func_call(context)
5032

51-
# Use a method defined above to get the values from the generator. Quick unwrap for easy access
33+
# Use orchestrator_generator_wrapper to get the values from the generator.
34+
# Processes the orchestrator in a way that is equivalent to the Durable replay logic
5235
values = [val for val in orchestrator_generator_wrapper(user_orchestrator)]
5336

5437
expected_activity_calls = [call('E2_GetFileList', 'C:/test'),

samples-v2/function_chaining/tests/test_my_orchestrator.py

Lines changed: 8 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,15 @@
1-
from datetime import timedelta
21
import unittest
32
from unittest.mock import Mock, call, patch
3+
from azure.durable_functions.testing import orchestrator_generator_wrapper
44

55
from function_app import my_orchestrator
66

7-
# A way to wrap an orchestrator generator to simplify calling it and getting the results.
8-
# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call,
9-
# we can unwrap the orchestrator generator using this method to simplify per-test code.
10-
def orchestrator_generator_wrapper(generator):
11-
previous = next(generator)
12-
yield previous
13-
while True:
14-
try:
15-
previous_result = None
16-
try:
17-
previous_result = previous.result
18-
except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw.
19-
previous = generator.throw(e)
20-
else:
21-
previous = generator.send(previous_result)
22-
yield previous
23-
except StopIteration as e:
24-
yield e.value
25-
return
26-
27-
28-
class MockTask():
29-
def __init__(self, result=None):
30-
self.result = result
31-
32-
33-
def mock_activity(activity_name, input):
7+
8+
@patch('azure.durable_functions.models.TaskBase')
9+
def mock_activity(activity_name, input, task):
3410
if activity_name == "say_hello":
35-
return MockTask(f"Hello {input}!")
11+
task.result = f"Hello {input}!"
12+
return task
3613
raise Exception("Activity not found")
3714

3815

@@ -47,7 +24,8 @@ def test_chaining_orchestrator(self, context):
4724
# Create a generator using the method and mocked context
4825
user_orchestrator = func_call(context)
4926

50-
# Use a method defined above to get the values from the generator. Quick unwrap for easy access
27+
# Use orchestrator_generator_wrapper to get the values from the generator.
28+
# Processes the orchestrator in a way that is equivalent to the Durable replay logic
5129
values = [val for val in orchestrator_generator_wrapper(user_orchestrator)]
5230

5331
expected_activity_calls = [call('say_hello', 'Tokyo'),

0 commit comments

Comments
 (0)