From 0e33ce379b353058af071038e80aaa67c51c6240 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 3 Apr 2025 14:19:25 -0600 Subject: [PATCH 01/26] Add change supporting unit testing - Support orchestrators and entities --- azure/durable_functions/entity.py | 48 ++++++++++++++++++------- azure/durable_functions/orchestrator.py | 42 ++++++++++++++++------ 2 files changed, 68 insertions(+), 22 deletions(-) diff --git a/azure/durable_functions/entity.py b/azure/durable_functions/entity.py index c025085..a9c7581 100644 --- a/azure/durable_functions/entity.py +++ b/azure/durable_functions/entity.py @@ -3,12 +3,45 @@ from datetime import datetime from typing import Callable, Any, List, Dict +import azure.functions as func class InternalEntityException(Exception): """Framework-internal Exception class (for internal use only).""" pass +class EntityHandler(Callable): + """Durable Entity Handler. + A callable class that wraps the user defined entity function for execution by the Python worker + and also allows access to the original method for unit testing + """ + + def __init__(self, func: Callable[[DurableEntityContext], None]): + """ + Create a new entity handler for the user defined entity function. + + Parameters + ---------- + func: Callable[[DurableEntityContext], None] + The user defined entity function. + """ + self._func = func + + def __call__(self, context: func.EntityContext) -> str: + """Handle the execution of the user defined entity function. + Parameters + ---------- + context : func.EntityContext + The DF entity context""" + # It is not clear when the context JSON would be found + # inside a "body"-key, but this pattern matches the + # orchestrator implementation, so we keep it for safety. + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + ctx, batch = DurableEntityContext.from_json(context_body) + return Entity(self._func).handle(ctx, batch) + class Entity: """Durable Entity Class. @@ -92,19 +125,10 @@ def create(cls, fn: Callable[[DurableEntityContext], None]) -> Callable[[Any], s Returns ------- - Callable[[Any], str] - Handle function of the newly created entity client + EntityHandler + Entity Handler callable for the newly created entity client """ - def handle(context) -> str: - # It is not clear when the context JSON would be found - # inside a "body"-key, but this pattern matches the - # orchestrator implementation, so we keep it for safety. - context_body = getattr(context, "body", None) - if context_body is None: - context_body = context - ctx, batch = DurableEntityContext.from_json(context_body) - return Entity(fn).handle(ctx, batch) - return handle + return EntityHandler(fn) def _elapsed_milliseconds_since(self, start_time: datetime) -> int: """Calculate the elapsed time, in milliseconds, from the start_time to the present. diff --git a/azure/durable_functions/orchestrator.py b/azure/durable_functions/orchestrator.py index 085f59d..5e5f731 100644 --- a/azure/durable_functions/orchestrator.py +++ b/azure/durable_functions/orchestrator.py @@ -11,6 +11,35 @@ import azure.functions as func +class OrchestrationHandler(Callable): + """Durable Orchestration Handler. + A callable class that wraps the user defined generator function for execution by the Python worker + and also allows access to the original method for unit testing + """ + + def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]]): + """ + Create a new orchestrator handler for the user defined orchestrator function. + + Parameters + ---------- + func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]] + The user defined orchestrator function. + """ + self._func = func + + def __call__(self, context: func.OrchestrationContext) -> str: + """Handle the execution of the user defined orchestrator function. + Parameters + ---------- + context : func.OrchestrationContext + The DF orchestration context""" + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + return Orchestrator(self._func).handle(DurableOrchestrationContext.from_json(context_body)) + + class Orchestrator: """Durable Orchestration Class. @@ -58,14 +87,7 @@ def create(cls, fn: Callable[[DurableOrchestrationContext], Generator[Any, Any, Returns ------- - Callable[[Any], str] - Handle function of the newly created orchestration client + OrchestrationHandler + Orchestration handler callable class for the newly created orchestration client """ - - def handle(context: func.OrchestrationContext) -> str: - context_body = getattr(context, "body", None) - if context_body is None: - context_body = context - return Orchestrator(fn).handle(DurableOrchestrationContext.from_json(context_body)) - - return handle + return OrchestrationHandler(fn) From 90500a2c6ad463fa3eeeefcb5af10e0ed926d2aa Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 3 Apr 2025 14:51:55 -0600 Subject: [PATCH 02/26] Add support for durable client functions --- azure/durable_functions/decorators/durable_app.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index 43f54bc..1f5c605 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -198,6 +198,17 @@ async def df_client_middleware(*args, **kwargs): # Invoke user code with rich DF Client binding return await user_code(*args, **kwargs) + # Todo: This feels awkward - however, there are two reasons that I can't naively implement + # this in the same way as entities and orchestrators: + # 1. We intentionally wrap this exported signature with @wraps, to preserve the original + # signature of the user code. This means that we can't just assign a new object to the + # fb._function._func, as that would overwrite the original signature. + # 2. I have not yet fully tested the behavior of overriding __call__ on an object with an + # async method. + # Here we lose type hinting and auto-documentation - not great. Need to find a better way + # to do this. + df_client_middleware._func = fb._function._func + user_code_with_rich_client = df_client_middleware fb._function._func = user_code_with_rich_client From ed470ce6f0683f1bbeaa9af100964a9d664387e4 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 18 Apr 2025 16:15:10 -0600 Subject: [PATCH 03/26] Naming --- azure/durable_functions/decorators/durable_app.py | 2 +- azure/durable_functions/entity.py | 4 ++-- azure/durable_functions/orchestrator.py | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index 1f5c605..f1c3845 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -207,7 +207,7 @@ async def df_client_middleware(*args, **kwargs): # async method. # Here we lose type hinting and auto-documentation - not great. Need to find a better way # to do this. - df_client_middleware._func = fb._function._func + df_client_middleware.client_function = fb._function._func user_code_with_rich_client = df_client_middleware fb._function._func = user_code_with_rich_client diff --git a/azure/durable_functions/entity.py b/azure/durable_functions/entity.py index a9c7581..e633496 100644 --- a/azure/durable_functions/entity.py +++ b/azure/durable_functions/entity.py @@ -25,7 +25,7 @@ def __init__(self, func: Callable[[DurableEntityContext], None]): func: Callable[[DurableEntityContext], None] The user defined entity function. """ - self._func = func + self.entity_function = func def __call__(self, context: func.EntityContext) -> str: """Handle the execution of the user defined entity function. @@ -40,7 +40,7 @@ def __call__(self, context: func.EntityContext) -> str: if context_body is None: context_body = context ctx, batch = DurableEntityContext.from_json(context_body) - return Entity(self._func).handle(ctx, batch) + return Entity(self.entity_function).handle(ctx, batch) class Entity: diff --git a/azure/durable_functions/orchestrator.py b/azure/durable_functions/orchestrator.py index 5e5f731..1b76363 100644 --- a/azure/durable_functions/orchestrator.py +++ b/azure/durable_functions/orchestrator.py @@ -26,7 +26,7 @@ def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]] The user defined orchestrator function. """ - self._func = func + self.orchestrator_function = func def __call__(self, context: func.OrchestrationContext) -> str: """Handle the execution of the user defined orchestrator function. @@ -37,7 +37,7 @@ def __call__(self, context: func.OrchestrationContext) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context - return Orchestrator(self._func).handle(DurableOrchestrationContext.from_json(context_body)) + return Orchestrator(self.orchestrator_function).handle(DurableOrchestrationContext.from_json(context_body)) class Orchestrator: From 69bd129d8649c4eccb76db2f8b57ac4bd57caf64 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 23 Apr 2025 14:30:25 -0600 Subject: [PATCH 04/26] Add test samples to fan_in_fan_out app --- .../tests/test_E2_BackupSiteContent.py | 75 +++++++++++++++++++ .../tests/test_E2_CopyFileToBlob.py | 1 + .../tests/test_E2_GetFileList.py | 1 + .../fan_in_fan_out/tests/test_HttpStart.py | 28 +++++++ 4 files changed, 105 insertions(+) create mode 100644 samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py create mode 100644 samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py create mode 100644 samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py create mode 100644 samples-v2/fan_in_fan_out/tests/test_HttpStart.py diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py new file mode 100644 index 0000000..10c945b --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -0,0 +1,75 @@ +from datetime import timedelta +import unittest +from unittest.mock import Mock, call, patch + +import azure.functions as func + +# import library_modifications + +from function_app import E2_BackupSiteContent + +# A way to wrap an orchestrator generator to simplify calling it and getting the results. +# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, +# we can simplify the orchestrator like this to also simplify per-test code. +def orchestrator_generator_wrapper(generator): + previous = next(generator) + yield previous + while True: + try: + previous_result = None + try: + previous_result = previous.result + except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw. + previous = generator.throw(e) + else: + previous = generator.send(previous_result) + yield previous + except StopIteration as e: + yield e.value + return + + +class MockTask(): + def __init__(self, result=None): + self.result = result + + +def mock_activity(activity_name, input): + if activity_name == "E2_GetFileList": + return MockTask(["C:/test/E2_Activity.py", "C:/test/E2_Orchestrator.py"]) + return MockTask(input) + + +def mock_task_any(task_index): + def internal_func(tasks): + return MockTask(tasks[task_index]) + # Simulate the behavior of task_any + return internal_func + + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationContext') + def test_E2_BackupSiteContent(self, context): + # Get the original method definition as seen in the function_app.py file + func_call = E2_BackupSiteContent.build().get_user_function().orchestrator_function + + context.get_input = Mock(return_value="C:/test") + context.call_activity = Mock(side_effect=mock_activity) + context.task_all = Mock(return_value=MockTask([100, 200, 300])) + + # Create a generator using the method and mocked context + user_orchestrator = func_call(context) + + # Use a method defined above to get the values from the generator. Quick unwrap for easy access + values = [val for val in orchestrator_generator_wrapper(user_orchestrator)] + + expected_activity_calls = [call('E2_GetFileList', 'C:/test'), + call('E2_CopyFileToBlob', 'C:/test/E2_Activity.py'), + call('E2_CopyFileToBlob', 'C:/test/E2_Orchestrator.py')] + + self.assertEqual(context.call_activity.call_count, 3) + self.assertEqual(context.call_activity.call_args_list, expected_activity_calls) + + context.task_all.assert_called_once() + # Sums the result of task_all + self.assertEqual(values[2], 600) diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py b/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py new file mode 100644 index 0000000..84f297f --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py @@ -0,0 +1 @@ +# Stub file - test this function with standard Azure Functions Python testing tools. \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py b/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py new file mode 100644 index 0000000..84f297f --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py @@ -0,0 +1 @@ +# Stub file - test this function with standard Azure Functions Python testing tools. \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py new file mode 100644 index 0000000..64b2d61 --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py @@ -0,0 +1,28 @@ +import asyncio +import unittest +import azure.functions as func +from unittest.mock import AsyncMock, Mock, patch + +from function_app import HttpStart + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationClient') + def test_HttpStart(self, client): + # Get the original method definition as seen in the function_app.py file + # func_call = chaining_orchestrator.build().get_user_function_unmodified() + func_call = HttpStart.build().get_user_function().client_function + + req = func.HttpRequest(method='GET', + body=b'{}', + url='/api/my_second_function', + route_params={"functionName": "E2_BackupSiteContent"}) + + client.start_new = AsyncMock(return_value="instance_id") + client.create_check_status_response = Mock(return_value="check_status_response") + + # Create a generator using the method and mocked context + result = asyncio.run(func_call(req, client)) + + client.start_new.assert_called_once_with("E2_BackupSiteContent", client_input={}) + client.create_check_status_response.assert_called_once_with(req, "instance_id") + self.assertEqual(result, "check_status_response") From a1282ebae83ae9f3215e878a40c0371e684e385f Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 23 Apr 2025 15:48:25 -0600 Subject: [PATCH 05/26] Linting fixes --- azure/durable_functions/decorators/durable_app.py | 2 +- azure/durable_functions/entity.py | 11 ++++++++--- azure/durable_functions/orchestrator.py | 9 ++++++--- 3 files changed, 15 insertions(+), 7 deletions(-) diff --git a/azure/durable_functions/decorators/durable_app.py b/azure/durable_functions/decorators/durable_app.py index f1c3845..62b5b70 100644 --- a/azure/durable_functions/decorators/durable_app.py +++ b/azure/durable_functions/decorators/durable_app.py @@ -206,7 +206,7 @@ async def df_client_middleware(*args, **kwargs): # 2. I have not yet fully tested the behavior of overriding __call__ on an object with an # async method. # Here we lose type hinting and auto-documentation - not great. Need to find a better way - # to do this. + # to do this. df_client_middleware.client_function = fb._function._func user_code_with_rich_client = df_client_middleware diff --git a/azure/durable_functions/entity.py b/azure/durable_functions/entity.py index e633496..a07448d 100644 --- a/azure/durable_functions/entity.py +++ b/azure/durable_functions/entity.py @@ -5,21 +5,24 @@ import azure.functions as func + class InternalEntityException(Exception): """Framework-internal Exception class (for internal use only).""" pass + class EntityHandler(Callable): """Durable Entity Handler. + A callable class that wraps the user defined entity function for execution by the Python worker and also allows access to the original method for unit testing """ - + def __init__(self, func: Callable[[DurableEntityContext], None]): """ Create a new entity handler for the user defined entity function. - + Parameters ---------- func: Callable[[DurableEntityContext], None] @@ -28,7 +31,9 @@ def __init__(self, func: Callable[[DurableEntityContext], None]): self.entity_function = func def __call__(self, context: func.EntityContext) -> str: - """Handle the execution of the user defined entity function. + """ + Handle the execution of the user defined entity function. + Parameters ---------- context : func.EntityContext diff --git a/azure/durable_functions/orchestrator.py b/azure/durable_functions/orchestrator.py index 1b76363..031c650 100644 --- a/azure/durable_functions/orchestrator.py +++ b/azure/durable_functions/orchestrator.py @@ -13,14 +13,15 @@ class OrchestrationHandler(Callable): """Durable Orchestration Handler. + A callable class that wraps the user defined generator function for execution by the Python worker and also allows access to the original method for unit testing """ - + def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]]): """ Create a new orchestrator handler for the user defined orchestrator function. - + Parameters ---------- func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]] @@ -29,7 +30,9 @@ def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, self.orchestrator_function = func def __call__(self, context: func.OrchestrationContext) -> str: - """Handle the execution of the user defined orchestrator function. + """ + Handle the execution of the user defined orchestrator function. + Parameters ---------- context : func.OrchestrationContext From 11cad729fb7e380c8200f182af75e5a2ae06e316 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 23 Apr 2025 15:54:31 -0600 Subject: [PATCH 06/26] Linting fixes 2 --- azure/durable_functions/entity.py | 6 ++++-- azure/durable_functions/orchestrator.py | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/azure/durable_functions/entity.py b/azure/durable_functions/entity.py index a07448d..c469f4a 100644 --- a/azure/durable_functions/entity.py +++ b/azure/durable_functions/entity.py @@ -31,8 +31,10 @@ def __init__(self, func: Callable[[DurableEntityContext], None]): self.entity_function = func def __call__(self, context: func.EntityContext) -> str: - """ - Handle the execution of the user defined entity function. + """Handle the execution of the user defined entity function. + + Serializes a DurableEntityContext object from the input context and + passes it to the entity function. Parameters ---------- diff --git a/azure/durable_functions/orchestrator.py b/azure/durable_functions/orchestrator.py index 031c650..44ec522 100644 --- a/azure/durable_functions/orchestrator.py +++ b/azure/durable_functions/orchestrator.py @@ -30,8 +30,10 @@ def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, self.orchestrator_function = func def __call__(self, context: func.OrchestrationContext) -> str: - """ - Handle the execution of the user defined orchestrator function. + """Handle the execution of the user defined orchestrator function. + + Serializes a DurableOrchestrationContext object from the input context and + passes it to the entity function. Parameters ---------- @@ -40,7 +42,9 @@ def __call__(self, context: func.OrchestrationContext) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context - return Orchestrator(self.orchestrator_function).handle(DurableOrchestrationContext.from_json(context_body)) + return Orchestrator(self.orchestrator_function).handle( + DurableOrchestrationContext.from_json(context_body) + ) class Orchestrator: From 2ef009f540068babaf3ce1075f112c7d229fad19 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 23 Apr 2025 15:58:47 -0600 Subject: [PATCH 07/26] Linter fixes 3 --- azure/durable_functions/entity.py | 9 ++++----- azure/durable_functions/orchestrator.py | 13 ++++++------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/azure/durable_functions/entity.py b/azure/durable_functions/entity.py index c469f4a..2853e15 100644 --- a/azure/durable_functions/entity.py +++ b/azure/durable_functions/entity.py @@ -31,15 +31,14 @@ def __init__(self, func: Callable[[DurableEntityContext], None]): self.entity_function = func def __call__(self, context: func.EntityContext) -> str: - """Handle the execution of the user defined entity function. - - Serializes a DurableEntityContext object from the input context and - passes it to the entity function. + """ + Handle the execution of the user defined entity function. Parameters ---------- context : func.EntityContext - The DF entity context""" + The DF entity context + """ # It is not clear when the context JSON would be found # inside a "body"-key, but this pattern matches the # orchestrator implementation, so we keep it for safety. diff --git a/azure/durable_functions/orchestrator.py b/azure/durable_functions/orchestrator.py index 44ec522..9f9a9bf 100644 --- a/azure/durable_functions/orchestrator.py +++ b/azure/durable_functions/orchestrator.py @@ -14,8 +14,8 @@ class OrchestrationHandler(Callable): """Durable Orchestration Handler. - A callable class that wraps the user defined generator function for execution by the Python worker - and also allows access to the original method for unit testing + A callable class that wraps the user defined generator function for execution + by the Python worker and also allows access to the original method for unit testing """ def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, Any, Any]]): @@ -30,15 +30,14 @@ def __init__(self, func: Callable[[DurableOrchestrationContext], Generator[Any, self.orchestrator_function = func def __call__(self, context: func.OrchestrationContext) -> str: - """Handle the execution of the user defined orchestrator function. - - Serializes a DurableOrchestrationContext object from the input context and - passes it to the entity function. + """ + Handle the execution of the user defined orchestrator function. Parameters ---------- context : func.OrchestrationContext - The DF orchestration context""" + The DF orchestration context + """ context_body = getattr(context, "body", None) if context_body is None: context_body = context From 4584c90d8e8f2496088c6cc3e89d556e196159e6 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 29 Apr 2025 14:54:56 -0600 Subject: [PATCH 08/26] Probable test issue fix --- .../fan_in_fan_out/tests/test_E2_BackupSiteContent.py | 9 +++++---- samples-v2/fan_in_fan_out/tests/test_HttpStart.py | 6 ++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py index 10c945b..5b8d2c2 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -1,10 +1,11 @@ -from datetime import timedelta import unittest from unittest.mock import Mock, call, patch -import azure.functions as func - -# import library_modifications +# This path manipulation allows the test to run in the Functions pipelines, and can be removed +# if this code is used as a sample for a different project. +import os +import sys +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.path.pardir)) from function_app import E2_BackupSiteContent diff --git a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py index 64b2d61..f72cdd7 100644 --- a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py +++ b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py @@ -3,6 +3,12 @@ import azure.functions as func from unittest.mock import AsyncMock, Mock, patch +# This path manipulation allows the test to run in the Functions pipelines, and can be removed +# if this code is used as a sample for a different project. +import os +import sys +sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.path.pardir)) + from function_app import HttpStart class TestFunction(unittest.TestCase): From 8f09b5a2df788abbbf0a45c1c5cd9efc3f3077a5 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 29 Apr 2025 15:04:16 -0600 Subject: [PATCH 09/26] Exclude samples from pytest github workflow --- .github/workflows/validate.yml | 2 +- .../fan_in_fan_out/tests/test_E2_BackupSiteContent.py | 6 ------ samples-v2/fan_in_fan_out/tests/test_HttpStart.py | 6 ------ 3 files changed, 1 insertion(+), 13 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 3a543f6..1de495d 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -31,4 +31,4 @@ jobs: flake8 . --count --show-source --statistics - name: Run tests run: | - pytest \ No newline at end of file + pytest --ignore=samples-v2 \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py index 5b8d2c2..a2da754 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -1,12 +1,6 @@ import unittest from unittest.mock import Mock, call, patch -# This path manipulation allows the test to run in the Functions pipelines, and can be removed -# if this code is used as a sample for a different project. -import os -import sys -sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.path.pardir)) - from function_app import E2_BackupSiteContent # A way to wrap an orchestrator generator to simplify calling it and getting the results. diff --git a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py index f72cdd7..64b2d61 100644 --- a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py +++ b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py @@ -3,12 +3,6 @@ import azure.functions as func from unittest.mock import AsyncMock, Mock, patch -# This path manipulation allows the test to run in the Functions pipelines, and can be removed -# if this code is used as a sample for a different project. -import os -import sys -sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__)), os.path.pardir)) - from function_app import HttpStart class TestFunction(unittest.TestCase): From 7902b65584dccab926bbff6dab955e36a4f7597f Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 2 May 2025 12:04:07 -0600 Subject: [PATCH 10/26] Add testing matrix for samples --- .github/workflows/validate.yml | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 1de495d..32734d6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -31,4 +31,28 @@ jobs: flake8 . --count --show-source --statistics - name: Run tests run: | - pytest --ignore=samples-v2 \ No newline at end of file + pytest --ignore=samples-v2 + + test-samples: + strategy: + matrix: + app_name: [blueprint, fan_in_fan_out, function_chaining] + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./samples-v2/${{ matrix.app_name }} + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.9 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + - name: Run tests + run: | + pytest \ No newline at end of file From d2f8d1e91b70c0ce9159edcb27645f1e63f9e8dd Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 2 May 2025 12:06:44 -0600 Subject: [PATCH 11/26] Pipeline fix --- .github/workflows/validate.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 32734d6..402b64b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -53,6 +53,8 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt + pip install pytest==8.3.5 + pip install pytest-asyncio==0.26.0 - name: Run tests run: | pytest \ No newline at end of file From 55886dba14d8736d2069b2c45a66e4a139e91ec9 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 2 May 2025 12:27:51 -0600 Subject: [PATCH 12/26] Build extension into tests --- .github/workflows/validate.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 402b64b..7b5c377 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -55,6 +55,7 @@ jobs: pip install -r requirements.txt pip install pytest==8.3.5 pip install pytest-asyncio==0.26.0 + pip install ../.. --no-cache-dir --upgrade --no-deps --force-reinstall - name: Run tests run: | - pytest \ No newline at end of file + python -m pytest From a9dd0e0aaabbf6959457bde967113d71a38f2f88 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 2 May 2025 13:04:52 -0600 Subject: [PATCH 13/26] Add tests to other projects --- .github/workflows/validate.yml | 4 +- samples-v2/blueprint/requirements.txt | 3 +- .../blueprint/tests/test_my_orchestrator.py | 58 +++++++++++++++++++ samples-v2/fan_in_fan_out/requirements.txt | 3 +- .../tests/test_E2_BackupSiteContent.py | 7 --- samples-v2/function_chaining/requirements.txt | 1 + .../tests/test_my_orchestrator.py | 58 +++++++++++++++++++ 7 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 samples-v2/blueprint/tests/test_my_orchestrator.py create mode 100644 samples-v2/function_chaining/tests/test_my_orchestrator.py diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 7b5c377..476b018 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -53,9 +53,7 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install pytest==8.3.5 - pip install pytest-asyncio==0.26.0 - pip install ../.. --no-cache-dir --upgrade --no-deps --force-reinstall + pip install ../.. --no-cache-dir --upgrade --force-reinstall - name: Run tests run: | python -m pytest diff --git a/samples-v2/blueprint/requirements.txt b/samples-v2/blueprint/requirements.txt index e1734ed..872c29c 100644 --- a/samples-v2/blueprint/requirements.txt +++ b/samples-v2/blueprint/requirements.txt @@ -3,4 +3,5 @@ # Manually managing azure-functions-worker may cause unexpected issues azure-functions -azure-functions-durable>=1.2.4 \ No newline at end of file +azure-functions-durable>=1.2.4 +pytest \ No newline at end of file diff --git a/samples-v2/blueprint/tests/test_my_orchestrator.py b/samples-v2/blueprint/tests/test_my_orchestrator.py new file mode 100644 index 0000000..ed321a7 --- /dev/null +++ b/samples-v2/blueprint/tests/test_my_orchestrator.py @@ -0,0 +1,58 @@ +from datetime import timedelta +import unittest +from unittest.mock import Mock, call, patch + +from durable_blueprints import my_orchestrator + +# A way to wrap an orchestrator generator to simplify calling it and getting the results. +# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, +# we can simplify the orchestrator like this to also simplify per-test code. +def orchestrator_generator_wrapper(generator): + previous = next(generator) + yield previous + while True: + try: + previous_result = None + try: + previous_result = previous.result + except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw. + previous = generator.throw(e) + else: + previous = generator.send(previous_result) + yield previous + except StopIteration as e: + yield e.value + return + + +class MockTask(): + def __init__(self, result=None): + self.result = result + + +def mock_activity(activity_name, input): + if activity_name == "say_hello": + return MockTask(f"Hello {input}!") + raise Exception("Activity not found") + + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationContext') + def test_chaining_orchestrator(self, context): + # Get the original method definition as seen in the function_app.py file + func_call = my_orchestrator.build().get_user_function().orchestrator_function + + context.call_activity = Mock(side_effect=mock_activity) + # Create a generator using the method and mocked context + user_orchestrator = func_call(context) + + # Use a method defined above to get the values from the generator. Quick unwrap for easy access + values = [val for val in orchestrator_generator_wrapper(user_orchestrator)] + + expected_activity_calls = [call('say_hello', 'Tokyo'), + call('say_hello', 'Seattle'), + call('say_hello', 'London')] + + self.assertEqual(context.call_activity.call_count, 3) + self.assertEqual(context.call_activity.call_args_list, expected_activity_calls) + self.assertEqual(values[3], ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]) diff --git a/samples-v2/fan_in_fan_out/requirements.txt b/samples-v2/fan_in_fan_out/requirements.txt index 1b13a44..f067980 100644 --- a/samples-v2/fan_in_fan_out/requirements.txt +++ b/samples-v2/fan_in_fan_out/requirements.txt @@ -4,4 +4,5 @@ azure-functions azure-functions-durable -azure-storage-blob \ No newline at end of file +azure-storage-blob +pytest \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py index a2da754..ec51080 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -35,13 +35,6 @@ def mock_activity(activity_name, input): return MockTask(input) -def mock_task_any(task_index): - def internal_func(tasks): - return MockTask(tasks[task_index]) - # Simulate the behavior of task_any - return internal_func - - class TestFunction(unittest.TestCase): @patch('azure.durable_functions.DurableOrchestrationContext') def test_E2_BackupSiteContent(self, context): diff --git a/samples-v2/function_chaining/requirements.txt b/samples-v2/function_chaining/requirements.txt index 58ba02b..d2fabc1 100644 --- a/samples-v2/function_chaining/requirements.txt +++ b/samples-v2/function_chaining/requirements.txt @@ -4,3 +4,4 @@ azure-functions azure-functions-durable +pytest diff --git a/samples-v2/function_chaining/tests/test_my_orchestrator.py b/samples-v2/function_chaining/tests/test_my_orchestrator.py new file mode 100644 index 0000000..e171397 --- /dev/null +++ b/samples-v2/function_chaining/tests/test_my_orchestrator.py @@ -0,0 +1,58 @@ +from datetime import timedelta +import unittest +from unittest.mock import Mock, call, patch + +from function_app import my_orchestrator + +# A way to wrap an orchestrator generator to simplify calling it and getting the results. +# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, +# we can simplify the orchestrator like this to also simplify per-test code. +def orchestrator_generator_wrapper(generator): + previous = next(generator) + yield previous + while True: + try: + previous_result = None + try: + previous_result = previous.result + except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw. + previous = generator.throw(e) + else: + previous = generator.send(previous_result) + yield previous + except StopIteration as e: + yield e.value + return + + +class MockTask(): + def __init__(self, result=None): + self.result = result + + +def mock_activity(activity_name, input): + if activity_name == "say_hello": + return MockTask(f"Hello {input}!") + raise Exception("Activity not found") + + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationContext') + def test_chaining_orchestrator(self, context): + # Get the original method definition as seen in the function_app.py file + func_call = my_orchestrator.build().get_user_function().orchestrator_function + + context.call_activity = Mock(side_effect=mock_activity) + # Create a generator using the method and mocked context + user_orchestrator = func_call(context) + + # Use a method defined above to get the values from the generator. Quick unwrap for easy access + values = [val for val in orchestrator_generator_wrapper(user_orchestrator)] + + expected_activity_calls = [call('say_hello', 'Tokyo'), + call('say_hello', 'Seattle'), + call('say_hello', 'London')] + + self.assertEqual(context.call_activity.call_count, 3) + self.assertEqual(context.call_activity.call_args_list, expected_activity_calls) + self.assertEqual(values[3], ["Hello Tokyo!", "Hello Seattle!", "Hello London!"]) From 7e10d5ee9d02e16625d313c6298d544d95b837bd Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 2 May 2025 13:08:10 -0600 Subject: [PATCH 14/26] Tweak script --- .github/workflows/validate.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 476b018..c1e81e7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -53,7 +53,8 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt - pip install ../.. --no-cache-dir --upgrade --force-reinstall + pip install -r ../../requirements.txt + pip install ../.. --no-cache-dir --upgrade --no-deps --force-reinstall - name: Run tests run: | python -m pytest From 545fbcc74946a6fa3a63f61af57a064ceda7d406 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Mon, 5 May 2025 13:03:24 -0600 Subject: [PATCH 15/26] Update tests from PR feedback --- samples-v2/blueprint/tests/readme.md | 51 +++++++++++++++++++ samples-v2/blueprint/tests/test_say_hello.py | 4 ++ .../tests/test_start_orchestrator.py | 27 ++++++++++ samples-v2/fan_in_fan_out/tests/readme.md | 51 +++++++++++++++++++ .../tests/test_E2_CopyFileToBlob.py | 5 +- .../tests/test_E2_GetFileList.py | 5 +- .../fan_in_fan_out/tests/test_HttpStart.py | 1 - samples-v2/function_chaining/tests/readme.md | 51 +++++++++++++++++++ .../tests/test_http_start.py | 27 ++++++++++ .../function_chaining/tests/test_say_hello.py | 4 ++ 10 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 samples-v2/blueprint/tests/readme.md create mode 100644 samples-v2/blueprint/tests/test_say_hello.py create mode 100644 samples-v2/blueprint/tests/test_start_orchestrator.py create mode 100644 samples-v2/fan_in_fan_out/tests/readme.md create mode 100644 samples-v2/function_chaining/tests/readme.md create mode 100644 samples-v2/function_chaining/tests/test_http_start.py create mode 100644 samples-v2/function_chaining/tests/test_say_hello.py diff --git a/samples-v2/blueprint/tests/readme.md b/samples-v2/blueprint/tests/readme.md new file mode 100644 index 0000000..2be9804 --- /dev/null +++ b/samples-v2/blueprint/tests/readme.md @@ -0,0 +1,51 @@ +# Durable Functions Sample – Unit Tests (Python) + +This directory contains a simple **unit test** for the sample [Durable Azure Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) written in Python. This test demonstrates how to validate the logic of the orchestrator function in isolation using mocks. + +## Prerequisites + +- Python +- [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (for running functions locally) +- [pytest](https://docs.pytest.org) for test execution +- VS Code with the **Python** and **Azure Functions** extensions (optional but recommended) + +--- + +## Running Tests from the Command Line + +1. Open a terminal or command prompt. +2. Navigate to the project root (where your `requirements.txt` is). +3. Create and activate a virtual environment: + +```bash +python -m venv .venv +.venv\Scripts\activate # On Windows +source .venv/bin/activate # On macOS/Linux +``` +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +Run tests: + +```bash +pytest +``` + +## Running Tests in Visual Studio Code +1. Open the project folder in VS Code. +2. Make sure the Python extension is installed. +3. Open the Command Palette (Ctrl+Shift+P), then select: +``` +Python: Configure Tests +``` +4. Choose pytest as the test framework. +5. Point to the tests/ folder when prompted. +6. Once configured, run tests from the Test Explorer panel or inline with the test code. + +Notes +- Tests use mocks to simulate Durable Functions' context objects. +- These are unit tests only; no real Azure services are called. +- For integration tests, consider starting the host with func start. \ No newline at end of file diff --git a/samples-v2/blueprint/tests/test_say_hello.py b/samples-v2/blueprint/tests/test_say_hello.py new file mode 100644 index 0000000..59dc528 --- /dev/null +++ b/samples-v2/blueprint/tests/test_say_hello.py @@ -0,0 +1,4 @@ +# Activity functions require no special implementation aside from standard Azure Functions +# unit testing for Python. As such, no test is implemented here. +# For more information about testing Azure Functions in Python, see the official documentation: +# https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python#unit-testing \ No newline at end of file diff --git a/samples-v2/blueprint/tests/test_start_orchestrator.py b/samples-v2/blueprint/tests/test_start_orchestrator.py new file mode 100644 index 0000000..6a9014a --- /dev/null +++ b/samples-v2/blueprint/tests/test_start_orchestrator.py @@ -0,0 +1,27 @@ +import asyncio +import unittest +import azure.functions as func +from unittest.mock import AsyncMock, Mock, patch + +from durable_blueprints import start_orchestrator + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationClient') + def test_HttpStart(self, client): + # Get the original method definition as seen in the function_app.py file + func_call = start_orchestrator.build().get_user_function().client_function + + req = func.HttpRequest(method='GET', + body=b'{}', + url='/api/my_second_function', + route_params={"functionName": "E2_BackupSiteContent"}) + + client.start_new = AsyncMock(return_value="instance_id") + client.create_check_status_response = Mock(return_value="check_status_response") + + # Create a generator using the method and mocked context + result = asyncio.run(func_call(req, client)) + + client.start_new.assert_called_once_with("E2_BackupSiteContent", client_input={}) + client.create_check_status_response.assert_called_once_with(req, "instance_id") + self.assertEqual(result, "check_status_response") diff --git a/samples-v2/fan_in_fan_out/tests/readme.md b/samples-v2/fan_in_fan_out/tests/readme.md new file mode 100644 index 0000000..2be9804 --- /dev/null +++ b/samples-v2/fan_in_fan_out/tests/readme.md @@ -0,0 +1,51 @@ +# Durable Functions Sample – Unit Tests (Python) + +This directory contains a simple **unit test** for the sample [Durable Azure Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) written in Python. This test demonstrates how to validate the logic of the orchestrator function in isolation using mocks. + +## Prerequisites + +- Python +- [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (for running functions locally) +- [pytest](https://docs.pytest.org) for test execution +- VS Code with the **Python** and **Azure Functions** extensions (optional but recommended) + +--- + +## Running Tests from the Command Line + +1. Open a terminal or command prompt. +2. Navigate to the project root (where your `requirements.txt` is). +3. Create and activate a virtual environment: + +```bash +python -m venv .venv +.venv\Scripts\activate # On Windows +source .venv/bin/activate # On macOS/Linux +``` +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +Run tests: + +```bash +pytest +``` + +## Running Tests in Visual Studio Code +1. Open the project folder in VS Code. +2. Make sure the Python extension is installed. +3. Open the Command Palette (Ctrl+Shift+P), then select: +``` +Python: Configure Tests +``` +4. Choose pytest as the test framework. +5. Point to the tests/ folder when prompted. +6. Once configured, run tests from the Test Explorer panel or inline with the test code. + +Notes +- Tests use mocks to simulate Durable Functions' context objects. +- These are unit tests only; no real Azure services are called. +- For integration tests, consider starting the host with func start. \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py b/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py index 84f297f..59dc528 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_CopyFileToBlob.py @@ -1 +1,4 @@ -# Stub file - test this function with standard Azure Functions Python testing tools. \ No newline at end of file +# Activity functions require no special implementation aside from standard Azure Functions +# unit testing for Python. As such, no test is implemented here. +# For more information about testing Azure Functions in Python, see the official documentation: +# https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python#unit-testing \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py b/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py index 84f297f..59dc528 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_GetFileList.py @@ -1 +1,4 @@ -# Stub file - test this function with standard Azure Functions Python testing tools. \ No newline at end of file +# Activity functions require no special implementation aside from standard Azure Functions +# unit testing for Python. As such, no test is implemented here. +# For more information about testing Azure Functions in Python, see the official documentation: +# https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python#unit-testing \ No newline at end of file diff --git a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py index 64b2d61..cc1c9c5 100644 --- a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py +++ b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py @@ -9,7 +9,6 @@ class TestFunction(unittest.TestCase): @patch('azure.durable_functions.DurableOrchestrationClient') def test_HttpStart(self, client): # Get the original method definition as seen in the function_app.py file - # func_call = chaining_orchestrator.build().get_user_function_unmodified() func_call = HttpStart.build().get_user_function().client_function req = func.HttpRequest(method='GET', diff --git a/samples-v2/function_chaining/tests/readme.md b/samples-v2/function_chaining/tests/readme.md new file mode 100644 index 0000000..2be9804 --- /dev/null +++ b/samples-v2/function_chaining/tests/readme.md @@ -0,0 +1,51 @@ +# Durable Functions Sample – Unit Tests (Python) + +This directory contains a simple **unit test** for the sample [Durable Azure Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) written in Python. This test demonstrates how to validate the logic of the orchestrator function in isolation using mocks. + +## Prerequisites + +- Python +- [Azure Functions Core Tools](https://learn.microsoft.com/azure/azure-functions/functions-run-local) (for running functions locally) +- [pytest](https://docs.pytest.org) for test execution +- VS Code with the **Python** and **Azure Functions** extensions (optional but recommended) + +--- + +## Running Tests from the Command Line + +1. Open a terminal or command prompt. +2. Navigate to the project root (where your `requirements.txt` is). +3. Create and activate a virtual environment: + +```bash +python -m venv .venv +.venv\Scripts\activate # On Windows +source .venv/bin/activate # On macOS/Linux +``` +Install dependencies: + +```bash +pip install -r requirements.txt +``` + +Run tests: + +```bash +pytest +``` + +## Running Tests in Visual Studio Code +1. Open the project folder in VS Code. +2. Make sure the Python extension is installed. +3. Open the Command Palette (Ctrl+Shift+P), then select: +``` +Python: Configure Tests +``` +4. Choose pytest as the test framework. +5. Point to the tests/ folder when prompted. +6. Once configured, run tests from the Test Explorer panel or inline with the test code. + +Notes +- Tests use mocks to simulate Durable Functions' context objects. +- These are unit tests only; no real Azure services are called. +- For integration tests, consider starting the host with func start. \ No newline at end of file diff --git a/samples-v2/function_chaining/tests/test_http_start.py b/samples-v2/function_chaining/tests/test_http_start.py new file mode 100644 index 0000000..ea782b4 --- /dev/null +++ b/samples-v2/function_chaining/tests/test_http_start.py @@ -0,0 +1,27 @@ +import asyncio +import unittest +import azure.functions as func +from unittest.mock import AsyncMock, Mock, patch + +from function_app import http_start + +class TestFunction(unittest.TestCase): + @patch('azure.durable_functions.DurableOrchestrationClient') + def test_HttpStart(self, client): + # Get the original method definition as seen in the function_app.py file + func_call = http_start.build().get_user_function().client_function + + req = func.HttpRequest(method='GET', + body=b'{}', + url='/api/my_second_function', + route_params={"functionName": "E2_BackupSiteContent"}) + + client.start_new = AsyncMock(return_value="instance_id") + client.create_check_status_response = Mock(return_value="check_status_response") + + # Create a generator using the method and mocked context + result = asyncio.run(func_call(req, client)) + + client.start_new.assert_called_once_with("E2_BackupSiteContent", client_input={}) + client.create_check_status_response.assert_called_once_with(req, "instance_id") + self.assertEqual(result, "check_status_response") diff --git a/samples-v2/function_chaining/tests/test_say_hello.py b/samples-v2/function_chaining/tests/test_say_hello.py new file mode 100644 index 0000000..59dc528 --- /dev/null +++ b/samples-v2/function_chaining/tests/test_say_hello.py @@ -0,0 +1,4 @@ +# Activity functions require no special implementation aside from standard Azure Functions +# unit testing for Python. As such, no test is implemented here. +# For more information about testing Azure Functions in Python, see the official documentation: +# https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python#unit-testing \ No newline at end of file From c5f3540fb7a39d34d5c759ada08b7d4e47d4c82a Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Mon, 5 May 2025 13:38:05 -0600 Subject: [PATCH 16/26] Fix tests --- samples-v2/blueprint/tests/test_start_orchestrator.py | 5 ++--- samples-v2/function_chaining/tests/test_http_start.py | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/samples-v2/blueprint/tests/test_start_orchestrator.py b/samples-v2/blueprint/tests/test_start_orchestrator.py index 6a9014a..a0845c0 100644 --- a/samples-v2/blueprint/tests/test_start_orchestrator.py +++ b/samples-v2/blueprint/tests/test_start_orchestrator.py @@ -13,8 +13,7 @@ def test_HttpStart(self, client): req = func.HttpRequest(method='GET', body=b'{}', - url='/api/my_second_function', - route_params={"functionName": "E2_BackupSiteContent"}) + url='/api/my_second_function') client.start_new = AsyncMock(return_value="instance_id") client.create_check_status_response = Mock(return_value="check_status_response") @@ -22,6 +21,6 @@ def test_HttpStart(self, client): # Create a generator using the method and mocked context result = asyncio.run(func_call(req, client)) - client.start_new.assert_called_once_with("E2_BackupSiteContent", client_input={}) + client.start_new.assert_called_once_with("my_orchestrator", client_input={}) client.create_check_status_response.assert_called_once_with(req, "instance_id") self.assertEqual(result, "check_status_response") diff --git a/samples-v2/function_chaining/tests/test_http_start.py b/samples-v2/function_chaining/tests/test_http_start.py index ea782b4..8067974 100644 --- a/samples-v2/function_chaining/tests/test_http_start.py +++ b/samples-v2/function_chaining/tests/test_http_start.py @@ -14,7 +14,7 @@ def test_HttpStart(self, client): req = func.HttpRequest(method='GET', body=b'{}', url='/api/my_second_function', - route_params={"functionName": "E2_BackupSiteContent"}) + route_params={"functionName": "my_orchestrator"}) client.start_new = AsyncMock(return_value="instance_id") client.create_check_status_response = Mock(return_value="check_status_response") @@ -22,6 +22,6 @@ def test_HttpStart(self, client): # Create a generator using the method and mocked context result = asyncio.run(func_call(req, client)) - client.start_new.assert_called_once_with("E2_BackupSiteContent", client_input={}) + client.start_new.assert_called_once_with("my_orchestrator", client_input={}) client.create_check_status_response.assert_called_once_with(req, "instance_id") self.assertEqual(result, "check_status_response") From f2db1cdff1a27bbe20ae3b0ef08b3294a7238997 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Mon, 5 May 2025 13:39:51 -0600 Subject: [PATCH 17/26] Fix tests --- samples-v2/function_chaining/tests/test_http_start.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples-v2/function_chaining/tests/test_http_start.py b/samples-v2/function_chaining/tests/test_http_start.py index 8067974..de6f5c0 100644 --- a/samples-v2/function_chaining/tests/test_http_start.py +++ b/samples-v2/function_chaining/tests/test_http_start.py @@ -22,6 +22,6 @@ def test_HttpStart(self, client): # Create a generator using the method and mocked context result = asyncio.run(func_call(req, client)) - client.start_new.assert_called_once_with("my_orchestrator", client_input={}) + client.start_new.assert_called_once_with("my_orchestrator") client.create_check_status_response.assert_called_once_with(req, "instance_id") self.assertEqual(result, "check_status_response") From 3a2f94b0202265944bd932fe57adb823ec8c1924 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Mon, 5 May 2025 13:41:01 -0600 Subject: [PATCH 18/26] Fix tests --- samples-v2/blueprint/tests/test_start_orchestrator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples-v2/blueprint/tests/test_start_orchestrator.py b/samples-v2/blueprint/tests/test_start_orchestrator.py index a0845c0..1779777 100644 --- a/samples-v2/blueprint/tests/test_start_orchestrator.py +++ b/samples-v2/blueprint/tests/test_start_orchestrator.py @@ -21,6 +21,6 @@ def test_HttpStart(self, client): # Create a generator using the method and mocked context result = asyncio.run(func_call(req, client)) - client.start_new.assert_called_once_with("my_orchestrator", client_input={}) + client.start_new.assert_called_once_with("my_orchestrator") client.create_check_status_response.assert_called_once_with(req, "instance_id") self.assertEqual(result, "check_status_response") From 0cb7871ba28bbc3f1dfac2acc5dd2cca12b2d346 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 7 May 2025 15:59:49 -0600 Subject: [PATCH 19/26] PR feedback --- samples-v2/blueprint/tests/readme.md | 21 +++++++++++++++++++ .../blueprint/tests/test_my_orchestrator.py | 2 +- .../tests/test_start_orchestrator.py | 2 +- samples-v2/fan_in_fan_out/tests/readme.md | 21 +++++++++++++++++++ .../tests/test_E2_BackupSiteContent.py | 4 ++-- .../fan_in_fan_out/tests/test_HttpStart.py | 2 +- samples-v2/function_chaining/tests/readme.md | 21 +++++++++++++++++++ .../tests/test_http_start.py | 2 +- .../tests/test_my_orchestrator.py | 3 ++- 9 files changed, 71 insertions(+), 7 deletions(-) diff --git a/samples-v2/blueprint/tests/readme.md b/samples-v2/blueprint/tests/readme.md index 2be9804..b483b52 100644 --- a/samples-v2/blueprint/tests/readme.md +++ b/samples-v2/blueprint/tests/readme.md @@ -1,7 +1,28 @@ # Durable Functions Sample – Unit Tests (Python) +## Overview + This directory contains a simple **unit test** for the sample [Durable Azure Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) written in Python. This test demonstrates how to validate the logic of the orchestrator function in isolation using mocks. +Writing unit tests for Durable functions requires sligtly different syntax for accessing the original method definition. Orchestrator functions, client functions, and entity functions all come with their own ways to access the user code: + +### Orchestrator functions +``` +my_orchestrator.build().get_user_function().orchestrator_function +``` + +### Client functions +``` +my_client_function.build().get_user_function().client_function +``` + +### Entity functions +``` +my_entity_function.build().get_user_function().entity_function +``` + +This sample app demonstrates using these accessors to get and test Durable functions. It also demonstrates how to mock the calling behavior that Durable uses to run orchestrators during replay with the orchestrator_generator_wrapper method defined in test_my_orchestrator.py and simulates the Tasks yielded by DurableOrchestrationContext with MockTask objects in the same file. + ## Prerequisites - Python diff --git a/samples-v2/blueprint/tests/test_my_orchestrator.py b/samples-v2/blueprint/tests/test_my_orchestrator.py index ed321a7..b27969d 100644 --- a/samples-v2/blueprint/tests/test_my_orchestrator.py +++ b/samples-v2/blueprint/tests/test_my_orchestrator.py @@ -6,7 +6,7 @@ # A way to wrap an orchestrator generator to simplify calling it and getting the results. # Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, -# we can simplify the orchestrator like this to also simplify per-test code. +# we can unwrap the orchestrator generator using this method to simplify per-test code. def orchestrator_generator_wrapper(generator): previous = next(generator) yield previous diff --git a/samples-v2/blueprint/tests/test_start_orchestrator.py b/samples-v2/blueprint/tests/test_start_orchestrator.py index 1779777..0f357b5 100644 --- a/samples-v2/blueprint/tests/test_start_orchestrator.py +++ b/samples-v2/blueprint/tests/test_start_orchestrator.py @@ -18,7 +18,7 @@ def test_HttpStart(self, client): client.start_new = AsyncMock(return_value="instance_id") client.create_check_status_response = Mock(return_value="check_status_response") - # Create a generator using the method and mocked context + # Execute the function code result = asyncio.run(func_call(req, client)) client.start_new.assert_called_once_with("my_orchestrator") diff --git a/samples-v2/fan_in_fan_out/tests/readme.md b/samples-v2/fan_in_fan_out/tests/readme.md index 2be9804..c22ea8f 100644 --- a/samples-v2/fan_in_fan_out/tests/readme.md +++ b/samples-v2/fan_in_fan_out/tests/readme.md @@ -1,7 +1,28 @@ # Durable Functions Sample – Unit Tests (Python) +## Overview + This directory contains a simple **unit test** for the sample [Durable Azure Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) written in Python. This test demonstrates how to validate the logic of the orchestrator function in isolation using mocks. +Writing unit tests for Durable functions requires sligtly different syntax for accessing the original method definition. Orchestrator functions, client functions, and entity functions all come with their own ways to access the user code: + +### Orchestrator functions +``` +my_orchestrator.build().get_user_function().orchestrator_function +``` + +### Client functions +``` +my_client_function.build().get_user_function().client_function +``` + +### Entity functions +``` +my_entity_function.build().get_user_function().entity_function +``` + +This sample app demonstrates using these accessors to get and test Durable functions. It also demonstrates how to mock the calling behavior that Durable uses to run orchestrators during replay with the orchestrator_generator_wrapper method defined in test_E2_BackupSiteContent.py and simulates the Tasks yielded by DurableOrchestrationContext with MockTask objects in the same file. + ## Prerequisites - Python diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py index ec51080..24c4b3d 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -5,7 +5,7 @@ # A way to wrap an orchestrator generator to simplify calling it and getting the results. # Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, -# we can simplify the orchestrator like this to also simplify per-test code. +# we can unwrap the orchestrator generator using this method to simplify per-test code. def orchestrator_generator_wrapper(generator): previous = next(generator) yield previous @@ -45,7 +45,7 @@ def test_E2_BackupSiteContent(self, context): context.call_activity = Mock(side_effect=mock_activity) context.task_all = Mock(return_value=MockTask([100, 200, 300])) - # Create a generator using the method and mocked context + # Execute the function code user_orchestrator = func_call(context) # Use a method defined above to get the values from the generator. Quick unwrap for easy access diff --git a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py index cc1c9c5..08002f8 100644 --- a/samples-v2/fan_in_fan_out/tests/test_HttpStart.py +++ b/samples-v2/fan_in_fan_out/tests/test_HttpStart.py @@ -19,7 +19,7 @@ def test_HttpStart(self, client): client.start_new = AsyncMock(return_value="instance_id") client.create_check_status_response = Mock(return_value="check_status_response") - # Create a generator using the method and mocked context + # Execute the function code result = asyncio.run(func_call(req, client)) client.start_new.assert_called_once_with("E2_BackupSiteContent", client_input={}) diff --git a/samples-v2/function_chaining/tests/readme.md b/samples-v2/function_chaining/tests/readme.md index 2be9804..b483b52 100644 --- a/samples-v2/function_chaining/tests/readme.md +++ b/samples-v2/function_chaining/tests/readme.md @@ -1,7 +1,28 @@ # Durable Functions Sample – Unit Tests (Python) +## Overview + This directory contains a simple **unit test** for the sample [Durable Azure Functions](https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview) written in Python. This test demonstrates how to validate the logic of the orchestrator function in isolation using mocks. +Writing unit tests for Durable functions requires sligtly different syntax for accessing the original method definition. Orchestrator functions, client functions, and entity functions all come with their own ways to access the user code: + +### Orchestrator functions +``` +my_orchestrator.build().get_user_function().orchestrator_function +``` + +### Client functions +``` +my_client_function.build().get_user_function().client_function +``` + +### Entity functions +``` +my_entity_function.build().get_user_function().entity_function +``` + +This sample app demonstrates using these accessors to get and test Durable functions. It also demonstrates how to mock the calling behavior that Durable uses to run orchestrators during replay with the orchestrator_generator_wrapper method defined in test_my_orchestrator.py and simulates the Tasks yielded by DurableOrchestrationContext with MockTask objects in the same file. + ## Prerequisites - Python diff --git a/samples-v2/function_chaining/tests/test_http_start.py b/samples-v2/function_chaining/tests/test_http_start.py index de6f5c0..6aa54c7 100644 --- a/samples-v2/function_chaining/tests/test_http_start.py +++ b/samples-v2/function_chaining/tests/test_http_start.py @@ -19,7 +19,7 @@ def test_HttpStart(self, client): client.start_new = AsyncMock(return_value="instance_id") client.create_check_status_response = Mock(return_value="check_status_response") - # Create a generator using the method and mocked context + # Execute the function code result = asyncio.run(func_call(req, client)) client.start_new.assert_called_once_with("my_orchestrator") diff --git a/samples-v2/function_chaining/tests/test_my_orchestrator.py b/samples-v2/function_chaining/tests/test_my_orchestrator.py index e171397..092a1b8 100644 --- a/samples-v2/function_chaining/tests/test_my_orchestrator.py +++ b/samples-v2/function_chaining/tests/test_my_orchestrator.py @@ -6,7 +6,7 @@ # A way to wrap an orchestrator generator to simplify calling it and getting the results. # Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, -# we can simplify the orchestrator like this to also simplify per-test code. +# we can unwrap the orchestrator generator using this method to simplify per-test code. def orchestrator_generator_wrapper(generator): previous = next(generator) yield previous @@ -43,6 +43,7 @@ def test_chaining_orchestrator(self, context): func_call = my_orchestrator.build().get_user_function().orchestrator_function context.call_activity = Mock(side_effect=mock_activity) + # Create a generator using the method and mocked context user_orchestrator = func_call(context) From 81b67f5879e82587e2c339a4e82772e82a2623d7 Mon Sep 17 00:00:00 2001 From: andystaples <77818326+andystaples@users.noreply.github.com> Date: Tue, 13 May 2025 13:25:41 -0700 Subject: [PATCH 20/26] Expose OrchestratorGeneratorWrapper in SDK (#548) * Expose OrchestratorGeneratorWrapper in SDK --- azure-functions-durable-python.sln | 29 ++++++++++++ azure/durable_functions/models/__init__.py | 4 +- .../testing/OrchestratorGeneratorWrapper.py | 38 ++++++++++++++++ azure/durable_functions/testing/__init__.py | 6 +++ .../blueprint/tests/test_my_orchestrator.py | 39 ++++------------ .../tests/test_E2_BackupSiteContent.py | 45 ++++++------------- .../tests/test_my_orchestrator.py | 38 ++++------------ 7 files changed, 106 insertions(+), 93 deletions(-) create mode 100644 azure-functions-durable-python.sln create mode 100644 azure/durable_functions/testing/OrchestratorGeneratorWrapper.py create mode 100644 azure/durable_functions/testing/__init__.py diff --git a/azure-functions-durable-python.sln b/azure-functions-durable-python.sln new file mode 100644 index 0000000..989543d --- /dev/null +++ b/azure-functions-durable-python.sln @@ -0,0 +1,29 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "extensions", "samples\aml_monitoring\extensions.csproj", "{33E598B8-4178-679F-9B92-BE8D8A64F1A5}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {33E598B8-4178-679F-9B92-BE8D8A64F1A5} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {AEA3AC93-4361-47CD-A8C7-CA280ABE1BDC} + EndGlobalSection +EndGlobal diff --git a/azure/durable_functions/models/__init__.py b/azure/durable_functions/models/__init__.py index a61511d..7737e9a 100644 --- a/azure/durable_functions/models/__init__.py +++ b/azure/durable_functions/models/__init__.py @@ -9,6 +9,7 @@ from .DurableHttpRequest import DurableHttpRequest from .TokenSource import ManagedIdentityTokenSource from .DurableEntityContext import DurableEntityContext +from .Task import TaskBase __all__ = [ 'DurableOrchestrationBindings', @@ -20,5 +21,6 @@ 'OrchestratorState', 'OrchestrationRuntimeStatus', 'PurgeHistoryResult', - 'RetryOptions' + 'RetryOptions', + 'TaskBase' ] diff --git a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py new file mode 100644 index 0000000..9790c8b --- /dev/null +++ b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py @@ -0,0 +1,38 @@ +from typing import Generator, Any, Union + +from azure.durable_functions.models import TaskBase + +def orchestrator_generator_wrapper(generator: Generator[TaskBase, Any, Any]) -> Generator[Union[TaskBase, Any], None, None]: + """Wraps a user-defined orchestrator function to simulate the Durable replay logic. + + Parameters + ---------- + generator: Generator[TaskBase, Any, Any] + Generator orchestrator as defined in the user function app. This generator is expected + to yield a series of TaskBase objects and receive the results of these tasks until + returning the result of the orchestrator. + + Returns + ------- + Generator[Union[TaskBase, Any], None, None] + A simplified version of the orchestrator which takes no inputs. This generator will + yield back the TaskBase objects that are yielded from the user orchestrator as well + as the final result of the orchestrator. Exception handling is also simulated here + in the same way as replay, where tasks returning exceptions are thrown back into the + orchestrator. + """ + previous = next(generator) + yield previous + while True: + try: + previous_result = None + try: + previous_result = previous.result + except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw. + previous = generator.throw(e) + else: + previous = generator.send(previous_result) + yield previous + except StopIteration as e: + yield e.value + return \ No newline at end of file diff --git a/azure/durable_functions/testing/__init__.py b/azure/durable_functions/testing/__init__.py new file mode 100644 index 0000000..19a2168 --- /dev/null +++ b/azure/durable_functions/testing/__init__.py @@ -0,0 +1,6 @@ +"""Unit testing utilities for Azure Durable functions.""" +from .OrchestratorGeneratorWrapper import orchestrator_generator_wrapper + +__all__ = [ + 'orchestrator_generator_wrapper' +] diff --git a/samples-v2/blueprint/tests/test_my_orchestrator.py b/samples-v2/blueprint/tests/test_my_orchestrator.py index b27969d..f989326 100644 --- a/samples-v2/blueprint/tests/test_my_orchestrator.py +++ b/samples-v2/blueprint/tests/test_my_orchestrator.py @@ -1,44 +1,20 @@ -from datetime import timedelta import unittest from unittest.mock import Mock, call, patch +from azure.durable_functions.testing import orchestrator_generator_wrapper from durable_blueprints import my_orchestrator -# A way to wrap an orchestrator generator to simplify calling it and getting the results. -# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, -# we can unwrap the orchestrator generator using this method to simplify per-test code. -def orchestrator_generator_wrapper(generator): - previous = next(generator) - yield previous - while True: - try: - previous_result = None - try: - previous_result = previous.result - except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw. - previous = generator.throw(e) - else: - previous = generator.send(previous_result) - yield previous - except StopIteration as e: - yield e.value - return - - -class MockTask(): - def __init__(self, result=None): - self.result = result - - -def mock_activity(activity_name, input): +@patch('azure.durable_functions.models.TaskBase') +def mock_activity(activity_name, input, task): if activity_name == "say_hello": - return MockTask(f"Hello {input}!") + task.result = f"Hello {input}!" + return task raise Exception("Activity not found") class TestFunction(unittest.TestCase): @patch('azure.durable_functions.DurableOrchestrationContext') - def test_chaining_orchestrator(self, context): + def test_my_orchestrator(self, context): # Get the original method definition as seen in the function_app.py file func_call = my_orchestrator.build().get_user_function().orchestrator_function @@ -46,7 +22,8 @@ def test_chaining_orchestrator(self, context): # Create a generator using the method and mocked context user_orchestrator = func_call(context) - # Use a method defined above to get the values from the generator. Quick unwrap for easy access + # Use orchestrator_generator_wrapper to get the values from the generator. + # Processes the orchestrator in a way that is equivalent to the Durable replay logic values = [val for val in orchestrator_generator_wrapper(user_orchestrator)] expected_activity_calls = [call('say_hello', 'Tokyo'), diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py index 24c4b3d..1e154bd 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -1,38 +1,20 @@ import unittest from unittest.mock import Mock, call, patch +from azure.durable_functions.testing import orchestrator_generator_wrapper from function_app import E2_BackupSiteContent -# A way to wrap an orchestrator generator to simplify calling it and getting the results. -# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, -# we can unwrap the orchestrator generator using this method to simplify per-test code. -def orchestrator_generator_wrapper(generator): - previous = next(generator) - yield previous - while True: - try: - previous_result = None - try: - previous_result = previous.result - except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw. - previous = generator.throw(e) - else: - previous = generator.send(previous_result) - yield previous - except StopIteration as e: - yield e.value - return - - -class MockTask(): - def __init__(self, result=None): - self.result = result - - -def mock_activity(activity_name, input): + +@patch('azure.durable_functions.models.TaskBase') +def create_mock_task(result, task): + task.result = result + return task + + +def mock_activity(activity_name, input, task): if activity_name == "E2_GetFileList": - return MockTask(["C:/test/E2_Activity.py", "C:/test/E2_Orchestrator.py"]) - return MockTask(input) + return create_mock_task(["C:/test/E2_Activity.py", "C:/test/E2_Orchestrator.py"]) + raise Exception("Activity not found") class TestFunction(unittest.TestCase): @@ -43,12 +25,13 @@ def test_E2_BackupSiteContent(self, context): context.get_input = Mock(return_value="C:/test") context.call_activity = Mock(side_effect=mock_activity) - context.task_all = Mock(return_value=MockTask([100, 200, 300])) + context.task_all = Mock(return_value=create_mock_task([100, 200, 300])) # Execute the function code user_orchestrator = func_call(context) - # Use a method defined above to get the values from the generator. Quick unwrap for easy access + # Use orchestrator_generator_wrapper to get the values from the generator. + # Processes the orchestrator in a way that is equivalent to the Durable replay logic values = [val for val in orchestrator_generator_wrapper(user_orchestrator)] expected_activity_calls = [call('E2_GetFileList', 'C:/test'), diff --git a/samples-v2/function_chaining/tests/test_my_orchestrator.py b/samples-v2/function_chaining/tests/test_my_orchestrator.py index 092a1b8..a1b5efe 100644 --- a/samples-v2/function_chaining/tests/test_my_orchestrator.py +++ b/samples-v2/function_chaining/tests/test_my_orchestrator.py @@ -1,38 +1,15 @@ -from datetime import timedelta import unittest from unittest.mock import Mock, call, patch +from azure.durable_functions.testing import orchestrator_generator_wrapper from function_app import my_orchestrator -# A way to wrap an orchestrator generator to simplify calling it and getting the results. -# Because orchestrators in Durable Functions always accept the result of the previous activity for the next send() call, -# we can unwrap the orchestrator generator using this method to simplify per-test code. -def orchestrator_generator_wrapper(generator): - previous = next(generator) - yield previous - while True: - try: - previous_result = None - try: - previous_result = previous.result - except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw. - previous = generator.throw(e) - else: - previous = generator.send(previous_result) - yield previous - except StopIteration as e: - yield e.value - return - - -class MockTask(): - def __init__(self, result=None): - self.result = result - - -def mock_activity(activity_name, input): + +@patch('azure.durable_functions.models.TaskBase') +def mock_activity(activity_name, input, task): if activity_name == "say_hello": - return MockTask(f"Hello {input}!") + task.result = f"Hello {input}!" + return task raise Exception("Activity not found") @@ -47,7 +24,8 @@ def test_chaining_orchestrator(self, context): # Create a generator using the method and mocked context user_orchestrator = func_call(context) - # Use a method defined above to get the values from the generator. Quick unwrap for easy access + # Use orchestrator_generator_wrapper to get the values from the generator. + # Processes the orchestrator in a way that is equivalent to the Durable replay logic values = [val for val in orchestrator_generator_wrapper(user_orchestrator)] expected_activity_calls = [call('say_hello', 'Tokyo'), From 02ab1390600ea13cf5c1d86180d82ed80b917a42 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 13 May 2025 14:33:22 -0600 Subject: [PATCH 21/26] Linting fixes --- .../testing/OrchestratorGeneratorWrapper.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py index 9790c8b..964a4e8 100644 --- a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py +++ b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py @@ -2,8 +2,10 @@ from azure.durable_functions.models import TaskBase -def orchestrator_generator_wrapper(generator: Generator[TaskBase, Any, Any]) -> Generator[Union[TaskBase, Any], None, None]: - """Wraps a user-defined orchestrator function to simulate the Durable replay logic. + +def orchestrator_generator_wrapper(generator: Generator[TaskBase, Any, Any]) -> Generator[ + Union[TaskBase, Any], None, None]: + """Wrap a user-defined orchestrator function in a way that simulates the Durable replay logic. Parameters ---------- @@ -19,20 +21,22 @@ def orchestrator_generator_wrapper(generator: Generator[TaskBase, Any, Any]) -> yield back the TaskBase objects that are yielded from the user orchestrator as well as the final result of the orchestrator. Exception handling is also simulated here in the same way as replay, where tasks returning exceptions are thrown back into the - orchestrator. + orchestrator. """ - previous = next(generator) + previous = next(generator) yield previous while True: try: previous_result = None try: previous_result = previous.result - except Exception as e: # Simulated activity exceptions, timer interrupted exceptions, anytime a task would throw. + except Exception as e: + # Simulated activity exceptions, timer interrupted exceptions, + # or anytime a task would throw. previous = generator.throw(e) else: - previous = generator.send(previous_result) + previous = generator.send(previous_result) yield previous except StopIteration as e: yield e.value - return \ No newline at end of file + return From 739a9e8a45c687ea3bde1b2bf529b5907f435348 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 13 May 2025 14:35:42 -0600 Subject: [PATCH 22/26] Test fix --- samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py index 1e154bd..86a94d8 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -11,7 +11,7 @@ def create_mock_task(result, task): return task -def mock_activity(activity_name, input, task): +def mock_activity(activity_name, input): if activity_name == "E2_GetFileList": return create_mock_task(["C:/test/E2_Activity.py", "C:/test/E2_Orchestrator.py"]) raise Exception("Activity not found") From b489e0525231ab2fbcd2be77f258228b63cb2324 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 13 May 2025 14:38:02 -0600 Subject: [PATCH 23/26] Linting fix --- .../testing/OrchestratorGeneratorWrapper.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py index 964a4e8..cb37a85 100644 --- a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py +++ b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py @@ -3,8 +3,9 @@ from azure.durable_functions.models import TaskBase -def orchestrator_generator_wrapper(generator: Generator[TaskBase, Any, Any]) -> Generator[ - Union[TaskBase, Any], None, None]: +def orchestrator_generator_wrapper( + generator: Generator[TaskBase, Any, Any] + ) -> Generator[Union[TaskBase, Any], None, None]: """Wrap a user-defined orchestrator function in a way that simulates the Durable replay logic. Parameters From 234338112aefbefe3cbeefade72238afef435885 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 13 May 2025 14:52:53 -0600 Subject: [PATCH 24/26] More linting and test fixes --- .../testing/OrchestratorGeneratorWrapper.py | 2 +- .../fan_in_fan_out/tests/test_E2_BackupSiteContent.py | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py index cb37a85..8ee71b7 100644 --- a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py +++ b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py @@ -5,7 +5,7 @@ def orchestrator_generator_wrapper( generator: Generator[TaskBase, Any, Any] - ) -> Generator[Union[TaskBase, Any], None, None]: + ) -> Generator[Union[TaskBase, Any], None, None]: """Wrap a user-defined orchestrator function in a way that simulates the Durable replay logic. Parameters diff --git a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py index 86a94d8..8adc29b 100644 --- a/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py +++ b/samples-v2/fan_in_fan_out/tests/test_E2_BackupSiteContent.py @@ -14,9 +14,15 @@ def create_mock_task(result, task): def mock_activity(activity_name, input): if activity_name == "E2_GetFileList": return create_mock_task(["C:/test/E2_Activity.py", "C:/test/E2_Orchestrator.py"]) + elif activity_name == "E2_CopyFileToBlob": + return create_mock_task(1) raise Exception("Activity not found") +def mock_task_all(tasks): + return create_mock_task([t.result for t in tasks]) + + class TestFunction(unittest.TestCase): @patch('azure.durable_functions.DurableOrchestrationContext') def test_E2_BackupSiteContent(self, context): @@ -25,7 +31,7 @@ def test_E2_BackupSiteContent(self, context): context.get_input = Mock(return_value="C:/test") context.call_activity = Mock(side_effect=mock_activity) - context.task_all = Mock(return_value=create_mock_task([100, 200, 300])) + context.task_all = Mock(side_effect=mock_task_all) # Execute the function code user_orchestrator = func_call(context) @@ -43,4 +49,4 @@ def test_E2_BackupSiteContent(self, context): context.task_all.assert_called_once() # Sums the result of task_all - self.assertEqual(values[2], 600) + self.assertEqual(values[2], 2) From 850b2d0827e846d00158880f6efba6d3b8559b1f Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 13 May 2025 14:55:00 -0600 Subject: [PATCH 25/26] Linting again --- .../durable_functions/testing/OrchestratorGeneratorWrapper.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py index 8ee71b7..f14cedb 100644 --- a/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py +++ b/azure/durable_functions/testing/OrchestratorGeneratorWrapper.py @@ -4,8 +4,7 @@ def orchestrator_generator_wrapper( - generator: Generator[TaskBase, Any, Any] - ) -> Generator[Union[TaskBase, Any], None, None]: + generator: Generator[TaskBase, Any, Any]) -> Generator[Union[TaskBase, Any], None, None]: """Wrap a user-defined orchestrator function in a way that simulates the Durable replay logic. Parameters From 9ab1c1792dd17c3399663e7750519d9f6ce96486 Mon Sep 17 00:00:00 2001 From: andystaples <77818326+andystaples@users.noreply.github.com> Date: Thu, 22 May 2025 13:32:30 -0600 Subject: [PATCH 26/26] Delete azure-functions-durable-python.sln --- azure-functions-durable-python.sln | 29 ----------------------------- 1 file changed, 29 deletions(-) delete mode 100644 azure-functions-durable-python.sln diff --git a/azure-functions-durable-python.sln b/azure-functions-durable-python.sln deleted file mode 100644 index 989543d..0000000 --- a/azure-functions-durable-python.sln +++ /dev/null @@ -1,29 +0,0 @@ -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.5.2.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5D20AA90-6969-D8BD-9DCD-8634F4692FDA}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "extensions", "samples\aml_monitoring\extensions.csproj", "{33E598B8-4178-679F-9B92-BE8D8A64F1A5}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Debug|Any CPU.Build.0 = Debug|Any CPU - {33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Release|Any CPU.ActiveCfg = Release|Any CPU - {33E598B8-4178-679F-9B92-BE8D8A64F1A5}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(NestedProjects) = preSolution - {33E598B8-4178-679F-9B92-BE8D8A64F1A5} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {AEA3AC93-4361-47CD-A8C7-CA280ABE1BDC} - EndGlobalSection -EndGlobal