diff --git a/CHANGELOG.md b/CHANGELOG.md index 96e427d..c6870f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,15 @@ and yanked-release status are based on the ## Unreleased +### Fixed + +- Orchestrators can now return custom objects supported by the Durable Functions + JSON codec. Their output is encoded before it is nested in the orchestration + state, preserving the same class metadata envelope used by activity results + so strict-mode sub-orchestrator callers can reconstruct the declared type. + Strict-mode callers must supply `expected_type` or use a Python v2 + sub-orchestrator whose return annotation can be discovered automatically. + ## 1.7.0 - 2026-07-30 ### Added diff --git a/azure/durable_functions/models/TaskOrchestrationExecutor.py b/azure/durable_functions/models/TaskOrchestrationExecutor.py index 73fd63f..7df75bc 100644 --- a/azure/durable_functions/models/TaskOrchestrationExecutor.py +++ b/azure/durable_functions/models/TaskOrchestrationExecutor.py @@ -9,7 +9,7 @@ from collections import namedtuple import json from ..models.entities.ResponseMessage import ResponseMessage -from .utils.df_serialization import df_loads +from .utils.df_serialization import df_dumps, df_loads class TaskOrchestrationExecutor: @@ -326,14 +326,18 @@ def get_orchestrator_state_str(self) -> str: message contains in it the string representation of the orchestration's state """ - if (self.output is not None): + state_output = self.output + if self.output is not None: try: - # Attempt to serialize the output. If serialization fails, raise an - # error indicating that the orchestration output is not serializable, - # which is not permitted in durable Python functions. - json.dumps(self.output) + # Serialize the output before nesting it in OrchestratorState. In + # strict mode, custom objects must be top-level df_dumps values. + # Keeping their envelope in the output field preserves the wire + # format used by activity results and enables typed sub-orchestrator + # decoding without wrapping the OrchestratorState protocol itself. + state_output = json.loads(df_dumps(self.output)) except Exception as e: self.output = None + state_output = None self.exception = e exception_str = None @@ -345,7 +349,7 @@ def get_orchestrator_state_str(self) -> str: state = OrchestratorState( is_done=self.orchestration_invocation_succeeded, actions=self.context._actions, - output=self.output, + output=state_output, replay_schema=self.context._replay_schema, error=exception_str, custom_status=self.context.custom_status diff --git a/tests/orchestrator/test_serialization.py b/tests/orchestrator/test_serialization.py index d92966c..2211346 100644 --- a/tests/orchestrator/test_serialization.py +++ b/tests/orchestrator/test_serialization.py @@ -1,15 +1,34 @@ +import json +from dataclasses import dataclass + +import pytest + +import azure.functions._durable_functions as _sdk from azure.durable_functions.models.ReplaySchema import ReplaySchema from tests.test_utils.ContextBuilder import ContextBuilder -from .orchestrator_test_utils \ - import get_orchestration_state_result, assert_orchestration_state_equals, assert_valid_schema +from .orchestrator_test_utils import ( + assert_orchestration_state_equals, + assert_valid_schema, + get_orchestration_state_result, +) from azure.durable_functions.models.OrchestratorState import OrchestratorState +from azure.durable_functions.models.utils.df_serialization import df_loads + + +def base_expected_state( + output=None, + replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: + return OrchestratorState( + is_done=False, + actions=[], + output=output, + replay_schema=replay_schema.value) -def base_expected_state(output=None, replay_schema: ReplaySchema = ReplaySchema.V1) -> OrchestratorState: - return OrchestratorState(is_done=False, actions=[], output=output, replay_schema=replay_schema.value) def generator_function(context): return False + def test_serialization_of_False(): """Test that an orchestrator can return False.""" @@ -28,4 +47,96 @@ def test_serialization_of_False(): expected["output"] = False assert_valid_schema(result) - assert_orchestration_state_equals(expected, result) \ No newline at end of file + assert_orchestration_state_equals(expected, result) + + +@dataclass +class CustomResult: + message: str + + def to_json(self): + return {"message": self.message} + + @classmethod + def from_json(cls, data): + return cls(message=data["message"]) + + +def orchestrator_with_custom_output(context): + return CustomResult(message="Custom serialization test") + + +@pytest.mark.parametrize("strict", [False, True]) +def test_serialization_of_custom_output(monkeypatch, strict): + if strict: + monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "true") + else: + monkeypatch.delenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", raising=False) + + result = get_orchestration_state_result( + ContextBuilder("serialize custom class"), + orchestrator_with_custom_output) + + assert_valid_schema(result) + assert result["isDone"] is True + assert result["output"] == { + "__class__": "CustomResult", + "__module__": CustomResult.__module__, + "__data__": {"message": "Custom serialization test"}, + } + + +def orchestrator_calling_typed_sub_orchestrator(context): + result = yield context.call_sub_orchestrator( + "CustomOutputOrchestrator", expected_type=CustomResult) + return result.message + + +def orchestrator_calling_untyped_sub_orchestrator(context): + yield context.call_sub_orchestrator("CustomOutputOrchestrator") + + +def test_strict_custom_output_can_be_decoded_by_sub_orchestrator(monkeypatch): + monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "true") + child_state = get_orchestration_state_result( + ContextBuilder("serialize custom class"), + orchestrator_with_custom_output) + child_output = json.dumps(child_state["output"]) + + context_builder = ContextBuilder("consume custom class") + context_builder.add_sub_orchestrator_started_event( + name="CustomOutputOrchestrator", id_=0, input_="") + context_builder.add_orchestrator_completed_event() + context_builder.add_orchestrator_started_event() + context_builder.add_sub_orchestrator_completed_event( + result=child_output, id_=0) + + result = get_orchestration_state_result( + context_builder, orchestrator_calling_typed_sub_orchestrator) + + assert result["isDone"] is True + assert result["output"] == "Custom serialization test" + assert df_loads(child_output, expected_type=CustomResult) == CustomResult( + message="Custom serialization test") + + +@pytest.mark.skipif( + not hasattr(_sdk, "df_loads"), + reason="strict typing is unavailable with the legacy SDK serializer") +def test_strict_custom_output_requires_sub_orchestrator_type(monkeypatch): + monkeypatch.setenv("AZURE_FUNCTIONS_DURABLE_STRICT_TYPING", "true") + child_state = get_orchestration_state_result( + ContextBuilder("serialize custom class"), + orchestrator_with_custom_output) + + context_builder = ContextBuilder("consume custom class") + context_builder.add_sub_orchestrator_started_event( + name="CustomOutputOrchestrator", id_=0, input_="") + context_builder.add_orchestrator_completed_event() + context_builder.add_orchestrator_started_event() + context_builder.add_sub_orchestrator_completed_event( + result=json.dumps(child_state["output"]), id_=0) + + with pytest.raises(TypeError, match="strict mode requires expected_type"): + get_orchestration_state_result( + context_builder, orchestrator_calling_untyped_sub_orchestrator)