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 c025085d..a9c75818 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 085f59d9..5e5f731e 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 43f54bc0..1f5c6053 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 1f5c6053..f1c3845c 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 a9c75818..e6334961 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 5e5f731e..1b763637 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 00000000..10c945be --- /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 00000000..84f297f7 --- /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 00000000..84f297f7 --- /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 00000000..64b2d614 --- /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 f1c3845c..62b5b704 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 e6334961..a07448db 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 1b763637..031c6502 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 a07448db..c469f4af 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 031c6502..44ec5226 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 c469f4af..2853e159 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 44ec5226..9f9a9bf7 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 10c945be..5b8d2c22 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 64b2d614..f72cdd7e 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 3a543f6f..1de495d5 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 5b8d2c22..a2da7544 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 f72cdd7e..64b2d614 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 1de495d5..32734d66 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 32734d66..402b64bb 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 402b64bb..7b5c3778 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 7b5c3778..476b0186 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 e1734eda..872c29ca 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 00000000..ed321a77 --- /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 1b13a440..f067980f 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 a2da7544..ec51080a 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 58ba02bf..d2fabc19 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 00000000..e1713979 --- /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 476b0186..c1e81e77 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 00000000..2be98040 --- /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 00000000..59dc528c --- /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 00000000..6a9014a5 --- /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 00000000..2be98040 --- /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 84f297f7..59dc528c 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 84f297f7..59dc528c 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 64b2d614..cc1c9c59 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 00000000..2be98040 --- /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 00000000..ea782b44 --- /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 00000000..59dc528c --- /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 6a9014a5..a0845c09 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 ea782b44..80679747 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 80679747..de6f5c0a 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 a0845c09..17797770 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 2be98040..b483b523 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 ed321a77..b27969dc 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 17797770..0f357b56 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 2be98040..c22ea8fc 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 ec51080a..24c4b3db 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 cc1c9c59..08002f86 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 2be98040..b483b523 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 de6f5c0a..6aa54c7b 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 e1713979..092a1b84 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 00000000..989543d2 --- /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 a61511d2..7737e9ae 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 00000000..9790c8b3 --- /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 00000000..19a21681 --- /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 b27969dc..f9893261 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 24c4b3db..1e154bd7 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 092a1b84..a1b5efe6 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 9790c8b3..964a4e80 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 1e154bd7..86a94d82 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 964a4e80..cb37a851 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 cb37a851..8ee71b7b 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 86a94d82..8adc29ba 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 8ee71b7b..f14cedb7 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 989543d2..00000000 --- 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