From 7ff1525d9c14001176b26a4e56af584b9391d920 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 6 Nov 2025 10:01:56 -0700 Subject: [PATCH 01/33] Storing changes commit --- durabletask-azurefunctions/CHANGELOG.md | 10 + durabletask-azurefunctions/__init__.py | 0 .../durabletask/azurefunctions/__init__.py | 0 .../durabletask/azurefunctions/constants.py | 10 + .../azurefunctions/decorators/__init__.py | 11 + .../azurefunctions/decorators/durable_app.py | 193 ++++++++++++++++++ .../azurefunctions/decorators/metadata.py | 109 ++++++++++ .../internal/DurableClientConverter.py | 46 +++++ .../azurefunctions/internal/__init__.py | 3 + .../durabletask/azurefunctions/worker.py | 2 + durabletask-azurefunctions/pyproject.toml | 43 ++++ 11 files changed, 427 insertions(+) create mode 100644 durabletask-azurefunctions/CHANGELOG.md create mode 100644 durabletask-azurefunctions/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/constants.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/worker.py create mode 100644 durabletask-azurefunctions/pyproject.toml diff --git a/durabletask-azurefunctions/CHANGELOG.md b/durabletask-azurefunctions/CHANGELOG.md new file mode 100644 index 00000000..b9be1590 --- /dev/null +++ b/durabletask-azurefunctions/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## v0.1.0 + +- Initial implementation diff --git a/durabletask-azurefunctions/__init__.py b/durabletask-azurefunctions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py new file mode 100644 index 00000000..78c9792b --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py @@ -0,0 +1,10 @@ +"""Constants used to determine the local running context.""" +# Todo: Remove unused constants after module is complete +DEFAULT_LOCAL_HOST: str = 'localhost:7071' +DEFAULT_LOCAL_ORIGIN: str = f'http://{DEFAULT_LOCAL_HOST}' +DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' +HTTP_ACTION_NAME = 'BuiltIn::HttpActivity' +ORCHESTRATION_TRIGGER = "orchestrationTrigger" +ACTIVITY_TRIGGER = "activityTrigger" +ENTITY_TRIGGER = "entityTrigger" +DURABLE_CLIENT = "durableClient" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py new file mode 100644 index 00000000..f3cfb910 --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py @@ -0,0 +1,11 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Durable Task SDK for Python entities component""" + +import durabletask.azurefunctions.decorators.durable_app as durable_app +import durabletask.azurefunctions.decorators.metadata as metadata + +__all__ = ["durable_app", "metadata"] + +PACKAGE_NAME = "durabletask.entities" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py new file mode 100644 index 00000000..152f6d1f --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -0,0 +1,193 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ + DurableClient +from typing import Callable, Optional +from typing import Union +from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel, OrchestrationContext + + +class Blueprint(TriggerApi, BindingApi): + """Durable Functions (DF) Blueprint container. + + It allows functions to be declared via trigger and binding decorators, + but does not automatically index/register these functions. + + To register these functions, utilize the `register_functions` method from any + :class:`FunctionRegister` subclass, such as `DFApp`. + """ + + def __init__(self, + http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION): + """Instantiate a Durable Functions app with which to register Functions. + + Parameters + ---------- + http_auth_level: Union[AuthLevel, str] + Authorization level required for Function invocation. + Defaults to AuthLevel.Function. + + Returns + ------- + DFApp + New instance of a Durable Functions app + """ + super().__init__(auth_level=http_auth_level) + + def _configure_orchestrator_callable(self, wrap) -> Callable: + """Obtain decorator to construct an Orchestrator class from a user-defined Function. + + In the old programming model, this decorator's logic was unavoidable boilerplate + in user-code. Now, this is handled internally by the framework. + + Parameters + ---------- + wrap: Callable + The next decorator to be applied. + + Returns + ------- + Callable + The function to construct an Orchestrator class from the user-defined Function, + wrapped by the next decorator in the sequence. + """ + def decorator(orchestrator_func): + # Construct an orchestrator based on the end-user code + + # TODO: Extract this logic (?) + def handle(context: OrchestrationContext) -> str: + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + orchestration_context = context_body + # TODO: Run the orchestration using the context + return "" + + handle.orchestrator_function = orchestrator_func + + # invoke next decorator, with the Orchestrator as input + handle.__name__ = orchestrator_func.__name__ + return wrap(handle) + + return decorator + + def orchestration_trigger(self, context_name: str, + orchestration: Optional[str] = None): + """Register an Orchestrator Function. + + Parameters + ---------- + context_name: str + Parameter name of the DurableOrchestrationContext object. + orchestration: Optional[str] + Name of Orchestrator Function. + The value is None by default, in which case the name of the method is used. + """ + @self._configure_orchestrator_callable + @self._configure_function_builder + def wrap(fb): + + def decorator(): + fb.add_trigger( + trigger=OrchestrationTrigger(name=context_name, + orchestration=orchestration)) + return fb + + return decorator() + + return wrap + + def activity_trigger(self, input_name: str, + activity: Optional[str] = None): + """Register an Activity Function. + + Parameters + ---------- + input_name: str + Parameter name of the Activity input. + activity: Optional[str] + Name of Activity Function. + The value is None by default, in which case the name of the method is used. + """ + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_trigger( + trigger=ActivityTrigger(name=input_name, + activity=activity)) + return fb + + return decorator() + + return wrap + + def entity_trigger(self, context_name: str, + entity_name: Optional[str] = None): + """Register an Entity Function. + + Parameters + ---------- + context_name: str + Parameter name of the Entity input. + entity_name: Optional[str] + Name of Entity Function. + The value is None by default, in which case the name of the method is used. + """ + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_trigger( + trigger=EntityTrigger(name=context_name, + entity_name=entity_name)) + return fb + + return decorator() + + return wrap + + def durable_client_input(self, + client_name: str, + task_hub: Optional[str] = None, + connection_name: Optional[str] = None + ): + """Register a Durable-client Function. + + Parameters + ---------- + client_name: str + Parameter name of durable client. + task_hub: Optional[str] + Used in scenarios where multiple function apps share the same storage account + but need to be isolated from each other. If not specified, the default value + from host.json is used. + This value must match the value used by the target orchestrator functions. + connection_name: Optional[str] + The name of an app setting that contains a storage account connection string. + The storage account represented by this connection string must be the same one + used by the target orchestrator functions. If not specified, the default storage + account connection string for the function app is used. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + # self._add_rich_client(fb, client_name, DurableOrchestrationClient) + + fb.add_binding( + binding=DurableClient(name=client_name, + task_hub=task_hub, + connection_name=connection_name)) + return fb + + return decorator() + + return wrap + + +class DFApp(Blueprint, FunctionRegister): + """Durable Functions (DF) app. + + Exports the decorators required to declare and index DF Function-types. + """ + + pass diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py new file mode 100644 index 00000000..4bf1d6c5 --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py @@ -0,0 +1,109 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Optional + +from durabletask.azurefunctions.constants import ORCHESTRATION_TRIGGER, \ + ACTIVITY_TRIGGER, ENTITY_TRIGGER, DURABLE_CLIENT +from azure.functions.decorators.core import Trigger, InputBinding + + +class OrchestrationTrigger(Trigger): + """OrchestrationTrigger. + + Trigger representing an Orchestration Function. + """ + + @staticmethod + def get_binding_name() -> str: + """Get the name of this trigger, as a string. + + Returns + ------- + str + The string representation of this trigger. + """ + return ORCHESTRATION_TRIGGER + + def __init__(self, + name: str, + orchestration: Optional[str] = None, + ) -> None: + self.orchestration = orchestration + super().__init__(name=name) + + +class ActivityTrigger(Trigger): + """ActivityTrigger. + + Trigger representing a Durable Functions Activity. + """ + + @staticmethod + def get_binding_name() -> str: + """Get the name of this trigger, as a string. + + Returns + ------- + str + The string representation of this trigger. + """ + return ACTIVITY_TRIGGER + + def __init__(self, + name: str, + activity: Optional[str] = None, + ) -> None: + self.activity = activity + super().__init__(name=name) + + +class EntityTrigger(Trigger): + """EntityTrigger. + + Trigger representing an Entity Function. + """ + + @staticmethod + def get_binding_name() -> str: + """Get the name of this trigger, as a string. + + Returns + ------- + str + The string representation of this trigger. + """ + return ENTITY_TRIGGER + + def __init__(self, + name: str, + entity_name: Optional[str] = None, + ) -> None: + self.entity_name = entity_name + super().__init__(name=name) + + +class DurableClient(InputBinding): + """DurableClient. + + Binding representing a Durable-client object. + """ + + @staticmethod + def get_binding_name() -> str: + """Get the name of this Binding, as a string. + + Returns + ------- + str + The string representation of this binding. + """ + return DURABLE_CLIENT + + def __init__(self, + name: str, + task_hub: Optional[str] = None, + connection_name: Optional[str] = None + ) -> None: + self.task_hub = task_hub + self.connection_name = connection_name + super().__init__(name=name) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py new file mode 100644 index 00000000..4286967a --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py @@ -0,0 +1,46 @@ +import abc +from typing import Any, Optional + +from azure.functions import meta + + +class DurableInConverter(meta._BaseConverter, binding=None): + + @classmethod + @abc.abstractmethod + def check_input_type_annotation(cls, pytype: type) -> bool: + pass + + @classmethod + @abc.abstractmethod + def decode(cls, data: meta.Datum, *, trigger_metadata) -> Any: + raise NotImplementedError + + @classmethod + @abc.abstractmethod + def has_implicit_output(cls) -> bool: + return False + + +class DurableOutConverter(meta._BaseConverter, binding=None): + + @classmethod + @abc.abstractmethod + def check_output_type_annotation(cls, pytype: type) -> bool: + pass + + @classmethod + @abc.abstractmethod + def encode(cls, obj: Any, *, + expected_type: Optional[type]) -> Optional[meta.Datum]: + raise NotImplementedError + +# Durable Functions Durable Client Bindings + + +class DurableClientConverter(DurableInConverter, + DurableOutConverter, + binding='durableClient'): + @classmethod + def has_implicit_output(cls) -> bool: + return False diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py new file mode 100644 index 00000000..d5823cf5 --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py @@ -0,0 +1,3 @@ +from .DurableClientConverter import DurableClientConverter + +__all__ = ["DurableClientConverter"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py new file mode 100644 index 00000000..a176672e --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -0,0 +1,2 @@ +class TempClass: + pass diff --git a/durabletask-azurefunctions/pyproject.toml b/durabletask-azurefunctions/pyproject.toml new file mode 100644 index 00000000..dfb02eb8 --- /dev/null +++ b/durabletask-azurefunctions/pyproject.toml @@ -0,0 +1,43 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# For more information on pyproject.toml, see https://peps.python.org/pep-0621/ + +[build-system] +requires = ["setuptools", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "durabletask.azurefunctions" +version = "0.1.0" +description = "Durable Task Python SDK provider implementation for Durable Azure Functions" +keywords = [ + "durable", + "task", + "workflow", + "azure", + "azure functions" +] +classifiers = [ + "Development Status :: 3 - Alpha", + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", +] +requires-python = ">=3.9" +license = {file = "LICENSE"} +readme = "README.md" +dependencies = [ + "durabletask>=0.5.0", + "azure-identity>=1.19.0", + "azure-functions>=1.11.0" +] + +[project.urls] +repository = "https://github.com/microsoft/durabletask-python" +changelog = "https://github.com/microsoft/durabletask-python/blob/main/CHANGELOG.md" + +[tool.setuptools.packages.find] +include = ["durabletask.azurefunctions", "durabletask.azurefunctions.*"] + +[tool.pytest.ini_options] +minversion = "6.0" From 552a2ddbe1d02730dea17c2442e10caac9d7788f Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 21 Nov 2025 13:46:16 -0700 Subject: [PATCH 02/33] Working orchestrators + activities --- .../durabletask/azurefunctions/client.py | 85 +++++++++++++++++ .../durabletask/azurefunctions/constants.py | 2 +- .../azurefunctions/decorators/durable_app.py | 91 +++++++++++++++++-- .../internal/DurableClientConverter.py | 46 ---------- .../azurefunctions/internal/__init__.py | 3 - .../azurefunctions_grpc_interceptor.py | 27 ++++++ .../internal/azurefunctions_null_stub.py | 39 ++++++++ .../durabletask/azurefunctions/worker.py | 34 ++++++- .../ProtoTaskHubSidecarServiceStub.py | 35 +++++++ durabletask/worker.py | 6 +- 10 files changed, 306 insertions(+), 62 deletions(-) create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/client.py delete mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py create mode 100644 durabletask/internal/ProtoTaskHubSidecarServiceStub.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py new file mode 100644 index 00000000..63a267bc --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -0,0 +1,85 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json + +from datetime import timedelta +from typing import Any, Optional +import azure.functions as func + +from durabletask.entities import EntityInstanceId +from durabletask.client import TaskHubGrpcClient +from durabletask.azurefunctions.internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl + + +# Client class used for Durable Functions +class DurableFunctionsClient(TaskHubGrpcClient): + taskHubName: str + connectionName: str + creationUrls: dict[str, str] + managementUrls: dict[str, str] + baseUrl: str + requiredQueryStringParameters: str + rpcBaseUrl: str + httpBaseUrl: str + maxGrpcMessageSizeInBytes: int + grpcHttpClientTimeout: timedelta + + def __init__(self, client_as_string: str): + client = json.loads(client_as_string) + + self.taskHubName = client.get("taskHubName", "") + self.connectionName = client.get("connectionName", "") + self.creationUrls = client.get("creationUrls", {}) + self.managementUrls = client.get("managementUrls", {}) + self.baseUrl = client.get("baseUrl", "") + self.requiredQueryStringParameters = client.get("requiredQueryStringParameters", "") + self.rpcBaseUrl = client.get("rpcBaseUrl", "") + self.httpBaseUrl = client.get("httpBaseUrl", "") + self.maxGrpcMessageSizeInBytes = client.get("maxGrpcMessageSizeInBytes", 0) + # TODO: convert the string value back to timedelta - annoying regex? + self.grpcHttpClientTimeout = client.get("grpcHttpClientTimeout", timedelta(seconds=30)) + interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] + + # We pass in None for the metadata so we don't construct an additional interceptor in the parent class + # Since the parent class doesn't use anything metadata for anything else, we can set it as None + super().__init__( + host_address=self.rpcBaseUrl, + secure_channel=False, + metadata=None, + interceptors=interceptors) + + def create_check_status_response(self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse: + """Creates an HTTP response for checking the status of a Durable Function instance. + + Args: + request (func.HttpRequest): The incoming HTTP request. + instance_id (str): The ID of the Durable Function instance. + """ + raise NotImplementedError("This method is not implemented yet.") + + def create_http_management_payload(self, instance_id: str) -> dict[str, str]: + """Creates an HTTP management payload for a Durable Function instance. + + Args: + instance_id (str): The ID of the Durable Function instance. + """ + raise NotImplementedError("This method is not implemented yet.") + + def read_entity_state( + self, + entity_id: EntityInstanceId, + task_hub_name: Optional[str], + connection_name: Optional[str] + ) -> tuple[bool, Any]: + """Reads the state of a Durable Entity. + + Args: + entity_id (str): The ID of the Durable Entity. + task_hub_name (Optional[str]): The name of the task hub. + connection_name (Optional[str]): The name of the connection. + + Returns: + (bool, Any): A tuple containing a boolean indicating if the entity exists and its state. + """ + raise NotImplementedError("This method is not implemented yet.") diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py index 78c9792b..652afcac 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py @@ -1,5 +1,5 @@ """Constants used to determine the local running context.""" -# Todo: Remove unused constants after module is complete +# TODO: Remove unused constants after module is complete DEFAULT_LOCAL_HOST: str = 'localhost:7071' DEFAULT_LOCAL_ORIGIN: str = f'http://{DEFAULT_LOCAL_HOST}' DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 152f6d1f..59ccc017 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -1,10 +1,19 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import base64 +from functools import wraps + +from durabletask.internal.orchestrator_service_pb2 import OrchestratorRequest, OrchestratorResponse from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional from typing import Union -from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel, OrchestrationContext +from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel + +# TODO: Use __init__.py to optimize imports +from durabletask.azurefunctions.client import DurableFunctionsClient +from durabletask.azurefunctions.worker import DurableFunctionsWorker +from durabletask.azurefunctions.internal.azurefunctions_null_stub import AzureFunctionsNullStub class Blueprint(TriggerApi, BindingApi): @@ -37,9 +46,6 @@ def __init__(self, def _configure_orchestrator_callable(self, wrap) -> Callable: """Obtain decorator to construct an Orchestrator class from a user-defined Function. - In the old programming model, this decorator's logic was unavoidable boilerplate - in user-code. Now, this is handled internally by the framework. - Parameters ---------- wrap: Callable @@ -54,14 +60,31 @@ def _configure_orchestrator_callable(self, wrap) -> Callable: def decorator(orchestrator_func): # Construct an orchestrator based on the end-user code - # TODO: Extract this logic (?) - def handle(context: OrchestrationContext) -> str: + # TODO: Move this logic somewhere better + def handle(context) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context orchestration_context = context_body - # TODO: Run the orchestration using the context - return "" + request = OrchestratorRequest() + request.ParseFromString(base64.b64decode(orchestration_context)) + stub = AzureFunctionsNullStub() + worker = DurableFunctionsWorker() + response: Optional[OrchestratorResponse] = None + + def stub_complete(stub_response): + nonlocal response + response = stub_response + stub.CompleteOrchestratorTask = stub_complete + execution_started_events = [e for e in [e1 for e1 in request.newEvents] + [e2 for e2 in request.pastEvents] if e.HasField("executionStarted")] + function_name = execution_started_events[-1].executionStarted.name + worker.add_named_orchestrator(function_name, orchestrator_func) + worker._execute_orchestrator(request, stub, None) + + if response is None: + raise Exception("Orchestrator execution did not produce a response.") + # The Python worker returns the input as type "json", so double-encoding is necessary + return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' handle.orchestrator_function = orchestrator_func @@ -71,6 +94,55 @@ def handle(context: OrchestrationContext) -> str: return decorator + def _configure_entity_callable(self, wrap) -> Callable: + """Obtain decorator to construct an Entity class from a user-defined Function. + + Parameters + ---------- + wrap: Callable + The next decorator to be applied. + + Returns + ------- + Callable + The function to construct an Entity class from the user-defined Function, + wrapped by the next decorator in the sequence. + """ + def decorator(entity_func): + # TODO: Implement entity support - similar to orchestrators (?) + raise NotImplementedError() + + return decorator + + def _add_rich_client(self, fb, parameter_name, + client_constructor): + # Obtain user-code and force type annotation on the client-binding parameter to be `str`. + # This ensures a passing type-check of that specific parameter, + # circumventing a limitation of the worker in type-checking rich DF Client objects. + # TODO: Once rich-binding type checking is possible, remove the annotation change. + user_code = fb._function._func + user_code.__annotations__[parameter_name] = str + + # `wraps` This ensures we re-export the same method-signature as the decorated method + @wraps(user_code) + async def df_client_middleware(*args, **kwargs): + + # Obtain JSON-string currently passed as DF Client, + # construct rich object from it, + # and assign parameter to that rich object + starter = kwargs[parameter_name] + client = client_constructor(starter) + kwargs[parameter_name] = client + + # Invoke user code with rich DF Client binding + return await user_code(*args, **kwargs) + + # TODO: Is there a better way to support retrieving the unwrapped user code? + df_client_middleware.client_function = fb._function._func # type: ignore + + user_code_with_rich_client = df_client_middleware + fb._function._func = user_code_with_rich_client + def orchestration_trigger(self, context_name: str, orchestration: Optional[str] = None): """Register an Orchestrator Function. @@ -133,6 +205,7 @@ def entity_trigger(self, context_name: str, Name of Entity Function. The value is None by default, in which case the name of the method is used. """ + @self._configure_entity_callable @self._configure_function_builder def wrap(fb): def decorator(): @@ -171,7 +244,7 @@ def durable_client_input(self, @self._configure_function_builder def wrap(fb): def decorator(): - # self._add_rich_client(fb, client_name, DurableOrchestrationClient) + self._add_rich_client(fb, client_name, DurableFunctionsClient) fb.add_binding( binding=DurableClient(name=client_name, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py deleted file mode 100644 index 4286967a..00000000 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/DurableClientConverter.py +++ /dev/null @@ -1,46 +0,0 @@ -import abc -from typing import Any, Optional - -from azure.functions import meta - - -class DurableInConverter(meta._BaseConverter, binding=None): - - @classmethod - @abc.abstractmethod - def check_input_type_annotation(cls, pytype: type) -> bool: - pass - - @classmethod - @abc.abstractmethod - def decode(cls, data: meta.Datum, *, trigger_metadata) -> Any: - raise NotImplementedError - - @classmethod - @abc.abstractmethod - def has_implicit_output(cls) -> bool: - return False - - -class DurableOutConverter(meta._BaseConverter, binding=None): - - @classmethod - @abc.abstractmethod - def check_output_type_annotation(cls, pytype: type) -> bool: - pass - - @classmethod - @abc.abstractmethod - def encode(cls, obj: Any, *, - expected_type: Optional[type]) -> Optional[meta.Datum]: - raise NotImplementedError - -# Durable Functions Durable Client Bindings - - -class DurableClientConverter(DurableInConverter, - DurableOutConverter, - binding='durableClient'): - @classmethod - def has_implicit_output(cls) -> bool: - return False diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py index d5823cf5..e69de29b 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py @@ -1,3 +0,0 @@ -from .DurableClientConverter import DurableClientConverter - -__all__ = ["DurableClientConverter"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py new file mode 100644 index 00000000..a457a5ee --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from importlib.metadata import version + +from durabletask.internal.grpc_interceptor import DefaultClientInterceptorImpl + + +class AzureFunctionsDefaultClientInterceptorImpl (DefaultClientInterceptorImpl): + """The class implements a UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor, + StreamUnaryClientInterceptor and StreamStreamClientInterceptor from grpc to add an + interceptor to add additional headers to all calls as needed.""" + required_query_string_parameters: str + + def __init__(self, taskhub_name: str, required_query_string_parameters: str): + self.required_query_string_parameters = required_query_string_parameters + try: + # Get the version of the azurefunctions package + sdk_version = version('durabletask-azurefunctions') + except Exception: + # Fallback if version cannot be determined + sdk_version = "unknown" + user_agent = f"durabletask-python/{sdk_version}" + self._metadata = [ + ("taskhub", taskhub_name), + ("x-user-agent", user_agent)] # 'user-agent' is a reserved header in grpc, so we use 'x-user-agent' instead + super().__init__(self._metadata) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py new file mode 100644 index 00000000..18b0116e --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py @@ -0,0 +1,39 @@ + +from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub + + +class AzureFunctionsNullStub(ProtoTaskHubSidecarServiceStub): + """Missing associated documentation comment in .proto file.""" + + def __init__(self): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Hello = lambda *args, **kwargs: None + self.StartInstance = lambda *args, **kwargs: None + self.GetInstance = lambda *args, **kwargs: None + self.RewindInstance = lambda *args, **kwargs: None + self.WaitForInstanceStart = lambda *args, **kwargs: None + self.WaitForInstanceCompletion = lambda *args, **kwargs: None + self.RaiseEvent = lambda *args, **kwargs: None + self.TerminateInstance = lambda *args, **kwargs: None + self.SuspendInstance = lambda *args, **kwargs: None + self.ResumeInstance = lambda *args, **kwargs: None + self.QueryInstances = lambda *args, **kwargs: None + self.PurgeInstances = lambda *args, **kwargs: None + self.GetWorkItems = lambda *args, **kwargs: None + self.CompleteActivityTask = lambda *args, **kwargs: None + self.CompleteOrchestratorTask = lambda *args, **kwargs: None + self.CompleteEntityTask = lambda *args, **kwargs: None + self.StreamInstanceHistory = lambda *args, **kwargs: None + self.CreateTaskHub = lambda *args, **kwargs: None + self.DeleteTaskHub = lambda *args, **kwargs: None + self.SignalEntity = lambda *args, **kwargs: None + self.GetEntity = lambda *args, **kwargs: None + self.QueryEntities = lambda *args, **kwargs: None + self.CleanEntityStorage = lambda *args, **kwargs: None + self.AbandonTaskActivityWorkItem = lambda *args, **kwargs: None + self.AbandonTaskOrchestratorWorkItem = lambda *args, **kwargs: None + self.AbandonTaskEntityWorkItem = lambda *args, **kwargs: None diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py index a176672e..a3e82237 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -1,2 +1,32 @@ -class TempClass: - pass +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from threading import Event +from durabletask.worker import _Registry, ConcurrencyOptions +from durabletask.internal import shared +from durabletask.worker import TaskHubGrpcWorker + + +# Worker class used for Durable Task Scheduler (DTS) +class DurableFunctionsWorker(TaskHubGrpcWorker): + """TOOD: Docs + """ + + def __init__(self): + # Don't call the parent constructor - we don't actually want to start an AsyncWorkerLoop + # or recieve work items from anywhere but the method that is creating this worker + self._registry = _Registry() + self._host_address = "" + self._logger = shared.get_logger("worker") + self._shutdown = Event() + self._is_running = False + self._secure_channel = False + + self._concurrency_options = ConcurrencyOptions() + + self._interceptors = None + + def add_named_orchestrator(self, name: str, func): + """TOOD: Docs + """ + self._registry.add_named_orchestrator(name, func) diff --git a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py new file mode 100644 index 00000000..9500b964 --- /dev/null +++ b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py @@ -0,0 +1,35 @@ +from typing import Any, Callable + + +class ProtoTaskHubSidecarServiceStub(object): + """TODO: Docs""" + + def __init__(self): + """Constructor. + """ + self.Hello: Callable[..., None] + self.StartInstance: Callable[..., None] + self.GetInstance: Callable[..., None] + self.RewindInstance: Callable[..., None] + self.WaitForInstanceStart: Callable[..., None] + self.WaitForInstanceCompletion: Callable[..., None] + self.RaiseEvent: Callable[..., None] + self.TerminateInstance: Callable[..., None] + self.SuspendInstance: Callable[..., None] + self.ResumeInstance: Callable[..., None] + self.QueryInstances: Callable[..., None] + self.PurgeInstances: Callable[..., None] + self.GetWorkItems: Callable[..., None] + self.CompleteActivityTask: Callable[..., None] + self.CompleteOrchestratorTask: Callable[..., None] + self.CompleteEntityTask: Callable[..., None] + self.StreamInstanceHistory: Callable[..., None] + self.CreateTaskHub: Callable[..., None] + self.DeleteTaskHub: Callable[..., None] + self.SignalEntity: Callable[..., None] + self.GetEntity: Callable[..., None] + self.QueryEntities: Callable[..., None] + self.CleanEntityStorage: Callable[..., None] + self.AbandonTaskActivityWorkItem: Callable[..., None] + self.AbandonTaskOrchestratorWorkItem: Callable[..., None] + self.AbandonTaskEntityWorkItem: Callable[..., None] diff --git a/durabletask/worker.py b/durabletask/worker.py index 09f6559b..f9f5f8d2 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -19,6 +19,7 @@ from google.protobuf import empty_pb2 from durabletask.internal import helpers +from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub from durabletask.internal.entity_state_shim import StateShim from durabletask.internal.helpers import new_timestamp from durabletask.entities import DurableEntity, EntityLock, EntityInstanceId, EntityContext @@ -625,7 +626,7 @@ def stop(self): def _execute_orchestrator( self, req: pb.OrchestratorRequest, - stub: stubs.TaskHubSidecarServiceStub, + stub: Union[stubs.TaskHubSidecarServiceStub, ProtoTaskHubSidecarServiceStub], completionToken, ): try: @@ -1689,6 +1690,9 @@ def process_event( self._logger.info(f"{ctx.instance_id}: Entity operation failed.") self._logger.info(f"Data: {json.dumps(event.entityOperationFailed)}") pass + elif event.HasField("orchestratorCompleted"): + # Added in Functions only (for some reason) and does not affect orchestrator flow + pass else: eventType = event.WhichOneof("eventType") raise task.OrchestrationStateError( From af0e3c2bc2580799dbcb1cdb9fd74bd398975a72 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 21 Nov 2025 14:36:16 -0700 Subject: [PATCH 03/33] Nitpicks and cleanup --- .../durabletask/azurefunctions/__init__.py | 2 ++ .../azurefunctions/decorators/durable_app.py | 12 +++++++++++- .../internal/azurefunctions_null_stub.py | 7 +++---- .../durabletask/azurefunctions/worker.py | 4 ++-- .../internal/ProtoTaskHubSidecarServiceStub.py | 7 +++++-- 5 files changed, 23 insertions(+), 9 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py index e69de29b..59e481eb 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 59ccc017..d4ae41cf 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + import base64 from functools import wraps @@ -76,7 +77,16 @@ def stub_complete(stub_response): nonlocal response response = stub_response stub.CompleteOrchestratorTask = stub_complete - execution_started_events = [e for e in [e1 for e1 in request.newEvents] + [e2 for e2 in request.pastEvents] if e.HasField("executionStarted")] + execution_started_events = [] + for e in request.pastEvents: + if e.HasField("executionStarted"): + execution_started_events.append(e) + for e in request.newEvents: + if e.HasField("executionStarted"): + execution_started_events.append(e) + if len(execution_started_events) == 0: + raise Exception("No ExecutionStarted event found in orchestration request.") + function_name = execution_started_events[-1].executionStarted.name worker.add_named_orchestrator(function_name, orchestrator_func) worker._execute_orchestrator(request, stub, None) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py index 18b0116e..47a0ce7e 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py @@ -1,15 +1,14 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub class AzureFunctionsNullStub(ProtoTaskHubSidecarServiceStub): - """Missing associated documentation comment in .proto file.""" + """A task hub sidecar stub class that implements all methods as no-ops.""" def __init__(self): """Constructor. - - Args: - channel: A grpc.Channel. """ self.Hello = lambda *args, **kwargs: None self.StartInstance = lambda *args, **kwargs: None diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py index a3e82237..8b4aca30 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -9,7 +9,7 @@ # Worker class used for Durable Task Scheduler (DTS) class DurableFunctionsWorker(TaskHubGrpcWorker): - """TOOD: Docs + """TODO: Docs """ def __init__(self): @@ -27,6 +27,6 @@ def __init__(self): self._interceptors = None def add_named_orchestrator(self, name: str, func): - """TOOD: Docs + """TODO: Docs """ self._registry.add_named_orchestrator(name, func) diff --git a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py index 9500b964..7ccfd589 100644 --- a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py +++ b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py @@ -1,8 +1,11 @@ -from typing import Any, Callable +from typing import Callable class ProtoTaskHubSidecarServiceStub(object): - """TODO: Docs""" + """A stub class roughly matching the TaskHubSidecarServiceStub generated from the .proto file. + Used by Azure Functions during orchestration and entity executions to inject custom behavior, + as no real sidecar stub is available. + """ def __init__(self): """Constructor. From 349714882fecdb6c5468309bb6ba62a383c835f7 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 21 Nov 2025 15:11:54 -0700 Subject: [PATCH 04/33] Save-all nits --- .../durabletask/azurefunctions/constants.py | 3 +++ .../durabletask/azurefunctions/decorators/__init__.py | 2 +- .../durabletask/azurefunctions/decorators/metadata.py | 1 + .../durabletask/azurefunctions/internal/__init__.py | 2 ++ .../azurefunctions/internal/azurefunctions_grpc_interceptor.py | 2 +- 5 files changed, 8 insertions(+), 2 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py index 652afcac..f647e31d 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + """Constants used to determine the local running context.""" # TODO: Remove unused constants after module is complete DEFAULT_LOCAL_HOST: str = 'localhost:7071' diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py index f3cfb910..59283bac 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py @@ -8,4 +8,4 @@ __all__ = ["durable_app", "metadata"] -PACKAGE_NAME = "durabletask.entities" +PACKAGE_NAME = "durabletask.azurefunctions.decorators" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py index 4bf1d6c5..30dc6ff5 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. + from typing import Optional from durabletask.azurefunctions.constants import ORCHESTRATION_TRIGGER, \ diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py index e69de29b..59e481eb 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py index a457a5ee..8736bf6f 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py @@ -6,7 +6,7 @@ from durabletask.internal.grpc_interceptor import DefaultClientInterceptorImpl -class AzureFunctionsDefaultClientInterceptorImpl (DefaultClientInterceptorImpl): +class AzureFunctionsDefaultClientInterceptorImpl(DefaultClientInterceptorImpl): """The class implements a UnaryUnaryClientInterceptor, UnaryStreamClientInterceptor, StreamUnaryClientInterceptor and StreamStreamClientInterceptor from grpc to add an interceptor to add additional headers to all calls as needed.""" From 57de87804e7ba8147d07625cf4ba9fd6b6ed0b5a Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Mon, 24 Nov 2025 11:59:18 -0700 Subject: [PATCH 05/33] Add entity support (needs extension change) --- .../azurefunctions/decorators/durable_app.py | 47 +++++++++++++++++-- durabletask/worker.py | 2 +- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index d4ae41cf..477db9a0 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -4,7 +4,7 @@ import base64 from functools import wraps -from durabletask.internal.orchestrator_service_pb2 import OrchestratorRequest, OrchestratorResponse +from durabletask.internal.orchestrator_service_pb2 import EntityRequest, EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional @@ -119,8 +119,49 @@ def _configure_entity_callable(self, wrap) -> Callable: wrapped by the next decorator in the sequence. """ def decorator(entity_func): - # TODO: Implement entity support - similar to orchestrators (?) - raise NotImplementedError() + # Construct an orchestrator based on the end-user code + + # TODO: Move this logic somewhere better + # TODO: Because this handle method is the one actually exposed to the Functions SDK decorator, + # the parameter name will always be "context" here, even if the user specified a different name. + # We need to find a way to allow custom context names (like "ctx"). + def handle(context) -> str: + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + orchestration_context = context_body + request = EntityBatchRequest() + request_2 = EntityRequest() + try: + request.ParseFromString(base64.b64decode(orchestration_context)) + except Exception: + pass + try: + request_2.ParseFromString(base64.b64decode(orchestration_context)) + except Exception: + pass + stub = AzureFunctionsNullStub() + worker = DurableFunctionsWorker() + response: Optional[EntityBatchResult] = None + + def stub_complete(stub_response: EntityBatchResult): + nonlocal response + response = stub_response + stub.CompleteEntityTask = stub_complete + + worker.add_entity(entity_func) + worker._execute_entity_batch(request, stub, None) + + if response is None: + raise Exception("Entity execution did not produce a response.") + # The Python worker returns the input as type "json", so double-encoding is necessary + return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' + + handle.entity_function = entity_func + + # invoke next decorator, with the Entity as input + handle.__name__ = entity_func.__name__ + return wrap(handle) return decorator diff --git a/durabletask/worker.py b/durabletask/worker.py index e84189e5..f3da1582 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -737,7 +737,7 @@ def _cancel_activity( def _execute_entity_batch( self, req: Union[pb.EntityBatchRequest, pb.EntityRequest], - stub: stubs.TaskHubSidecarServiceStub, + stub: Union[stubs.TaskHubSidecarServiceStub, ProtoTaskHubSidecarServiceStub], completionToken, ): if isinstance(req, pb.EntityRequest): From 18145f8f980785567ff3c16efec3da8fc41ea729 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 3 Dec 2025 08:59:42 -0700 Subject: [PATCH 06/33] Refine entity support - Still needs eventSent and eventRecieved implementations --- .../durabletask/azurefunctions/client.py | 42 ++++++++++--------- .../azurefunctions/decorators/durable_app.py | 14 ++----- .../azurefunctions/http/__init__.py | 6 +++ .../http/http_management_payload.py | 18 ++++++++ durabletask/internal/helpers.py | 7 +++- durabletask/worker.py | 10 ++++- 6 files changed, 64 insertions(+), 33 deletions(-) create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py index 63a267bc..0a4058b1 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -6,10 +6,12 @@ from datetime import timedelta from typing import Any, Optional import azure.functions as func +from urllib.parse import urlparse, quote from durabletask.entities import EntityInstanceId from durabletask.client import TaskHubGrpcClient from durabletask.azurefunctions.internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl +from durabletask.azurefunctions.http import HttpManagementPayload # Client class used for Durable Functions @@ -56,30 +58,32 @@ def create_check_status_response(self, request: func.HttpRequest, instance_id: s request (func.HttpRequest): The incoming HTTP request. instance_id (str): The ID of the Durable Function instance. """ - raise NotImplementedError("This method is not implemented yet.") + location_url = self._get_instance_status_url(request, instance_id) + return func.HttpResponse( + body=str(self._get_client_response_links(request, instance_id)), + status_code=501, + headers={ + 'content-type': 'application/json', + 'Location': location_url, + }, + ) - def create_http_management_payload(self, instance_id: str) -> dict[str, str]: + def create_http_management_payload(self, request: func.HttpRequest, instance_id: str) -> HttpManagementPayload: """Creates an HTTP management payload for a Durable Function instance. Args: instance_id (str): The ID of the Durable Function instance. """ - raise NotImplementedError("This method is not implemented yet.") + return self._get_client_response_links(request, instance_id) - def read_entity_state( - self, - entity_id: EntityInstanceId, - task_hub_name: Optional[str], - connection_name: Optional[str] - ) -> tuple[bool, Any]: - """Reads the state of a Durable Entity. + def _get_client_response_links(self, request: func.HttpRequest, instance_id: str) -> HttpManagementPayload: + instance_status_url = self._get_instance_status_url(request, instance_id) + return HttpManagementPayload(instance_id, instance_status_url, self.requiredQueryStringParameters) - Args: - entity_id (str): The ID of the Durable Entity. - task_hub_name (Optional[str]): The name of the task hub. - connection_name (Optional[str]): The name of the connection. - - Returns: - (bool, Any): A tuple containing a boolean indicating if the entity exists and its state. - """ - raise NotImplementedError("This method is not implemented yet.") + @staticmethod + def _get_instance_status_url(request: func.HttpRequest, instance_id: str) -> str: + request_url = urlparse(request.url) + location_url = f"{request_url.scheme}://{request_url.netloc}{request_url.path}" + encoded_instance_id = quote(instance_id) + location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_instance_id + return location_url diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 477db9a0..2a6489f5 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -96,7 +96,7 @@ def stub_complete(stub_response): # The Python worker returns the input as type "json", so double-encoding is necessary return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' - handle.orchestrator_function = orchestrator_func + handle.orchestrator_function = orchestrator_func # type: ignore # invoke next decorator, with the Orchestrator as input handle.__name__ = orchestrator_func.__name__ @@ -131,15 +131,7 @@ def handle(context) -> str: context_body = context orchestration_context = context_body request = EntityBatchRequest() - request_2 = EntityRequest() - try: - request.ParseFromString(base64.b64decode(orchestration_context)) - except Exception: - pass - try: - request_2.ParseFromString(base64.b64decode(orchestration_context)) - except Exception: - pass + request.ParseFromString(base64.b64decode(orchestration_context)) stub = AzureFunctionsNullStub() worker = DurableFunctionsWorker() response: Optional[EntityBatchResult] = None @@ -157,7 +149,7 @@ def stub_complete(stub_response: EntityBatchResult): # The Python worker returns the input as type "json", so double-encoding is necessary return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' - handle.entity_function = entity_func + handle.entity_function = entity_func # type: ignore # invoke next decorator, with the Entity as input handle.__name__ = entity_func.__name__ diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py new file mode 100644 index 00000000..fc1cb6ba --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py @@ -0,0 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from durabletask.azurefunctions.http.http_management_payload import HttpManagementPayload + +__all__ = ["HttpManagementPayload"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py new file mode 100644 index 00000000..1fb2a7cf --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py @@ -0,0 +1,18 @@ +import json + + +class HttpManagementPayload: + def __init__(self, instance_id: str, instance_status_url: str, required_query_string_parameters: str): + self.urls = { + 'id': instance_id, + 'purgeHistoryDeleteUri': instance_status_url + "?" + required_query_string_parameters, + 'restartPostUri': instance_status_url + "/restart?" + required_query_string_parameters, + 'sendEventPostUri': instance_status_url + "/raiseEvent/{eventName}?" + required_query_string_parameters, + 'statusQueryGetUri': instance_status_url + "?" + required_query_string_parameters, + 'terminatePostUri': instance_status_url + "/terminate?reason={text}&" + required_query_string_parameters, + 'resumePostUri': instance_status_url + "/resume?reason={text}&" + required_query_string_parameters, + 'suspendPostUri': instance_status_url + "/suspend?reason={text}&" + required_query_string_parameters + } + + def __str__(self): + return json.dumps(self.urls) diff --git a/durabletask/internal/helpers.py b/durabletask/internal/helpers.py index ccd8558b..f1ca8dfa 100644 --- a/durabletask/internal/helpers.py +++ b/durabletask/internal/helpers.py @@ -4,6 +4,7 @@ import traceback from datetime import datetime from typing import Optional +import uuid from google.protobuf import timestamp_pb2, wrappers_pb2 @@ -197,8 +198,9 @@ def new_schedule_task_action(id: int, name: str, encoded_input: Optional[str], def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]): + request_id = str(uuid.uuid4()) return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationCalled=pb.EntityOperationCalledEvent( - requestId=f"{parent_instance_id}:{id}", + requestId=request_id, operation=operation, scheduledTime=None, input=get_string_value(encoded_input), @@ -209,8 +211,9 @@ def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityIn def new_signal_entity_action(id: int, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]): + request_id = str(uuid.uuid4()) return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationSignaled=pb.EntityOperationSignaledEvent( - requestId=f"{entity_id}:{id}", + requestId=request_id, operation=operation, scheduledTime=None, input=get_string_value(encoded_input), diff --git a/durabletask/worker.py b/durabletask/worker.py index f3da1582..d5e35c54 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -13,6 +13,7 @@ from types import GeneratorType from enum import Enum from typing import Any, Generator, Optional, Sequence, TypeVar, Union +import uuid from packaging.version import InvalidVersion, parse import grpc @@ -740,6 +741,7 @@ def _execute_entity_batch( stub: Union[stubs.TaskHubSidecarServiceStub, ProtoTaskHubSidecarServiceStub], completionToken, ): + operation_infos = None if isinstance(req, pb.EntityRequest): req, operation_infos = helpers.convert_to_entity_batch_request(req) @@ -1200,7 +1202,7 @@ def lock_entities_function_helper(self, id: int, entities: list[EntityInstanceId if not transition_valid: raise RuntimeError(error_message) - critical_section_id = f"{self.instance_id}:{id:04x}" + critical_section_id = str(uuid.uuid4()) request, target = self._entity_context.emit_acquire_message(critical_section_id, entities) @@ -1747,6 +1749,12 @@ def process_event( elif event.HasField("orchestratorCompleted"): # Added in Functions only (for some reason) and does not affect orchestrator flow pass + elif event.HasField("eventSent"): + # Added in Functions only (for some reason) and does not affect orchestrator flow + pass + elif event.HasField("eventRaised"): + # Added in Functions only (for some reason) and does not affect orchestrator flow + pass else: eventType = event.WhichOneof("eventType") raise task.OrchestrationStateError( From 9965ba4100d0396325e511361ad4fea87545b522 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 3 Dec 2025 10:58:43 -0800 Subject: [PATCH 07/33] Finish entity support --- .../durabletask/azurefunctions/client.py | 2 +- durabletask/entities/entity_instance_id.py | 2 +- durabletask/worker.py | 79 ++++++++++++------- examples/entities/function_based_entity.py | 2 +- 4 files changed, 54 insertions(+), 31 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py index 0a4058b1..ffd9cd12 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -83,7 +83,7 @@ def _get_client_response_links(self, request: func.HttpRequest, instance_id: str @staticmethod def _get_instance_status_url(request: func.HttpRequest, instance_id: str) -> str: request_url = urlparse(request.url) - location_url = f"{request_url.scheme}://{request_url.netloc}{request_url.path}" + location_url = f"{request_url.scheme}://{request_url.netloc}" encoded_instance_id = quote(instance_id) location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_instance_id return location_url diff --git a/durabletask/entities/entity_instance_id.py b/durabletask/entities/entity_instance_id.py index 53c1171f..72335d11 100644 --- a/durabletask/entities/entity_instance_id.py +++ b/durabletask/entities/entity_instance_id.py @@ -20,7 +20,7 @@ def __lt__(self, other): return str(self) < str(other) @staticmethod - def parse(entity_id: str) -> Optional["EntityInstanceId"]: + def parse(entity_id: str) -> "EntityInstanceId": """Parse a string representation of an entity ID into an EntityInstanceId object. Parameters diff --git a/durabletask/worker.py b/durabletask/worker.py index d5e35c54..20657444 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -1593,33 +1593,52 @@ def process_event( else: raise TypeError("Unexpected sub-orchestration task type") elif event.HasField("eventRaised"): - # event names are case-insensitive - event_name = event.eventRaised.name.casefold() - if not ctx.is_replaying: - self._logger.info(f"{ctx.instance_id} Event raised: {event_name}") - task_list = ctx._pending_events.get(event_name, None) - decoded_result: Optional[Any] = None - if task_list: - event_task = task_list.pop(0) + if event.eventRaised.name in ctx._entity_task_id_map: + # This eventRaised represents the result of an entity operation after being translated to the old + # entity protocol by the Durable WebJobs extension + entity_id, task_id = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None)) + if entity_id is None: + raise RuntimeError(f"Could not retrieve entity ID for entity-related eventRaised with ID '{event.eventId}'") + if task_id is None: + raise RuntimeError(f"Could not retrieve task ID for entity-related eventRaised with ID '{event.eventId}'") + entity_task = ctx._pending_tasks.pop(task_id, None) + if not entity_task: + raise RuntimeError(f"Could not retrieve entity task for entity-related eventRaised with ID '{event.eventId}'") + result = None if not ph.is_empty(event.eventRaised.input): - decoded_result = shared.from_json(event.eventRaised.input.value) - event_task.complete(decoded_result) - if not task_list: - del ctx._pending_events[event_name] + # TODO: Investigate why the event result is wrapped in a dict with "result" key + result = shared.from_json(event.eventRaised.input.value)["result"] + ctx._entity_context.recover_lock_after_call(entity_id) + entity_task.complete(result) ctx.resume() else: - # buffer the event - event_list = ctx._received_events.get(event_name, None) - if not event_list: - event_list = [] - ctx._received_events[event_name] = event_list - if not ph.is_empty(event.eventRaised.input): - decoded_result = shared.from_json(event.eventRaised.input.value) - event_list.append(decoded_result) + # event names are case-insensitive + event_name = event.eventRaised.name.casefold() if not ctx.is_replaying: - self._logger.info( - f"{ctx.instance_id}: Event '{event_name}' has been buffered as there are no tasks waiting for it." - ) + self._logger.info(f"{ctx.instance_id} Event raised: {event_name}") + task_list = ctx._pending_events.get(event_name, None) + decoded_result: Optional[Any] = None + if task_list: + event_task = task_list.pop(0) + if not ph.is_empty(event.eventRaised.input): + decoded_result = shared.from_json(event.eventRaised.input.value) + event_task.complete(decoded_result) + if not task_list: + del ctx._pending_events[event_name] + ctx.resume() + else: + # buffer the event + event_list = ctx._received_events.get(event_name, None) + if not event_list: + event_list = [] + ctx._received_events[event_name] = event_list + if not ph.is_empty(event.eventRaised.input): + decoded_result = shared.from_json(event.eventRaised.input.value) + event_list.append(decoded_result) + if not ctx.is_replaying: + self._logger.info( + f"{ctx.instance_id}: Event '{event_name}' has been buffered as there are no tasks waiting for it." + ) elif event.HasField("executionSuspended"): if not self._is_suspended and not ctx.is_replaying: self._logger.info(f"{ctx.instance_id}: Execution suspended.") @@ -1750,11 +1769,15 @@ def process_event( # Added in Functions only (for some reason) and does not affect orchestrator flow pass elif event.HasField("eventSent"): - # Added in Functions only (for some reason) and does not affect orchestrator flow - pass - elif event.HasField("eventRaised"): - # Added in Functions only (for some reason) and does not affect orchestrator flow - pass + # Check if this eventSent corresponds to an entity operation call after being translated to the old + # entity protocol by the Durable WebJobs extension. If so, treat this message similarly to + # entityOperationCalled and remove the pending action. Also store the entity id and event id for later + action = ctx._pending_actions.pop(event.eventId, None) + if action and action.HasField("sendEntityMessage") and action.sendEntityMessage.HasField("entityOperationCalled"): + entity_id = EntityInstanceId.parse(event.eventSent.instanceId) + event_id = json.loads(event.eventSent.input.value)["id"] + ctx._entity_task_id_map[event_id] = (entity_id, event.eventId) + return else: eventType = event.WhichOneof("eventType") raise task.OrchestrationStateError( diff --git a/examples/entities/function_based_entity.py b/examples/entities/function_based_entity.py index a43b86d2..32d94692 100644 --- a/examples/entities/function_based_entity.py +++ b/examples/entities/function_based_entity.py @@ -13,7 +13,7 @@ def counter(ctx: entities.EntityContext, input: int) -> Optional[int]: if ctx.operation == "set": ctx.set_state(input) - if ctx.operation == "add": + elif ctx.operation == "add": current_state = ctx.get_state(int, 0) new_state = current_state + (input or 1) ctx.set_state(new_state) From 209443ec9df9e6eccb77a5279aa4de68fa71c692 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 4 Dec 2025 11:26:09 -0700 Subject: [PATCH 08/33] Fixes and improvements - Add new_uuid method to OrchestrationContext for deterministic replay-safe UUIDs - Fix entity locking behavior for Functions - Align _RuntimeOrchestrationContext param names with OrchestrationContext - Remap __init__.py files for new module - Update version to 0.0.1dev0 - Add docstrings to missing methods - Move code for executing orchestrators/entities to DurableFunctionsWorker - Add function metadata to triggers for detection by extension --- .../durabletask/azurefunctions/__init__.py | 5 ++ .../durabletask/azurefunctions/client.py | 16 +++++ .../durabletask/azurefunctions/constants.py | 5 -- .../azurefunctions/decorators/__init__.py | 9 --- .../azurefunctions/decorators/durable_app.py | 65 +---------------- .../azurefunctions/decorators/metadata.py | 6 +- .../http/http_management_payload.py | 13 ++++ .../durabletask/azurefunctions/worker.py | 67 ++++++++++++++++- durabletask-azurefunctions/pyproject.toml | 2 +- durabletask/internal/helpers.py | 15 ++-- durabletask/task.py | 16 +++++ durabletask/worker.py | 71 ++++++++++++++----- 12 files changed, 185 insertions(+), 105 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py index 59e481eb..c7680213 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py @@ -1,2 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. + +from durabletask.azurefunctions.decorators.durable_app import Blueprint, DFApp +from durabletask.azurefunctions.client import DurableFunctionsClient + +__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py index ffd9cd12..362ef899 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -16,6 +16,12 @@ # Client class used for Durable Functions class DurableFunctionsClient(TaskHubGrpcClient): + """A gRPC client passed to Durable Functions durable client bindings. + + Connects to the Durable Functions runtime using gRPC and provides methods + for creating and managing Durable orchestrations, interacting with Durable entities, + and creating HTTP management payloads and check status responses for use with Durable Functions invocations. + """ taskHubName: str connectionName: str creationUrls: dict[str, str] @@ -28,6 +34,16 @@ class DurableFunctionsClient(TaskHubGrpcClient): grpcHttpClientTimeout: timedelta def __init__(self, client_as_string: str): + """Initializes a DurableFunctionsClient instance from a JSON string. + + This string will be provided by the Durable Functions host extension upon invocation of the client trigger. + + Args: + client_as_string (str): A JSON string containing the Durable Functions client configuration. + + Raises: + json.JSONDecodeError: If the provided string is not valid JSON. + """ client = json.loads(client_as_string) self.taskHubName = client.get("taskHubName", "") diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py index f647e31d..fbd268a7 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/constants.py @@ -2,11 +2,6 @@ # Licensed under the MIT License. """Constants used to determine the local running context.""" -# TODO: Remove unused constants after module is complete -DEFAULT_LOCAL_HOST: str = 'localhost:7071' -DEFAULT_LOCAL_ORIGIN: str = f'http://{DEFAULT_LOCAL_HOST}' -DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' -HTTP_ACTION_NAME = 'BuiltIn::HttpActivity' ORCHESTRATION_TRIGGER = "orchestrationTrigger" ACTIVITY_TRIGGER = "activityTrigger" ENTITY_TRIGGER = "entityTrigger" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py index 59283bac..59e481eb 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py @@ -1,11 +1,2 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. - -"""Durable Task SDK for Python entities component""" - -import durabletask.azurefunctions.decorators.durable_app as durable_app -import durabletask.azurefunctions.decorators.metadata as metadata - -__all__ = ["durable_app", "metadata"] - -PACKAGE_NAME = "durabletask.azurefunctions.decorators" diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 2a6489f5..15a13e59 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -1,20 +1,16 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import base64 from functools import wraps -from durabletask.internal.orchestrator_service_pb2 import EntityRequest, EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional from typing import Union from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel -# TODO: Use __init__.py to optimize imports from durabletask.azurefunctions.client import DurableFunctionsClient from durabletask.azurefunctions.worker import DurableFunctionsWorker -from durabletask.azurefunctions.internal.azurefunctions_null_stub import AzureFunctionsNullStub class Blueprint(TriggerApi, BindingApi): @@ -61,40 +57,8 @@ def _configure_orchestrator_callable(self, wrap) -> Callable: def decorator(orchestrator_func): # Construct an orchestrator based on the end-user code - # TODO: Move this logic somewhere better def handle(context) -> str: - context_body = getattr(context, "body", None) - if context_body is None: - context_body = context - orchestration_context = context_body - request = OrchestratorRequest() - request.ParseFromString(base64.b64decode(orchestration_context)) - stub = AzureFunctionsNullStub() - worker = DurableFunctionsWorker() - response: Optional[OrchestratorResponse] = None - - def stub_complete(stub_response): - nonlocal response - response = stub_response - stub.CompleteOrchestratorTask = stub_complete - execution_started_events = [] - for e in request.pastEvents: - if e.HasField("executionStarted"): - execution_started_events.append(e) - for e in request.newEvents: - if e.HasField("executionStarted"): - execution_started_events.append(e) - if len(execution_started_events) == 0: - raise Exception("No ExecutionStarted event found in orchestration request.") - - function_name = execution_started_events[-1].executionStarted.name - worker.add_named_orchestrator(function_name, orchestrator_func) - worker._execute_orchestrator(request, stub, None) - - if response is None: - raise Exception("Orchestrator execution did not produce a response.") - # The Python worker returns the input as type "json", so double-encoding is necessary - return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' + return DurableFunctionsWorker()._execute_orchestrator(orchestrator_func, context) handle.orchestrator_function = orchestrator_func # type: ignore @@ -121,33 +85,11 @@ def _configure_entity_callable(self, wrap) -> Callable: def decorator(entity_func): # Construct an orchestrator based on the end-user code - # TODO: Move this logic somewhere better # TODO: Because this handle method is the one actually exposed to the Functions SDK decorator, # the parameter name will always be "context" here, even if the user specified a different name. # We need to find a way to allow custom context names (like "ctx"). def handle(context) -> str: - context_body = getattr(context, "body", None) - if context_body is None: - context_body = context - orchestration_context = context_body - request = EntityBatchRequest() - request.ParseFromString(base64.b64decode(orchestration_context)) - stub = AzureFunctionsNullStub() - worker = DurableFunctionsWorker() - response: Optional[EntityBatchResult] = None - - def stub_complete(stub_response: EntityBatchResult): - nonlocal response - response = stub_response - stub.CompleteEntityTask = stub_complete - - worker.add_entity(entity_func) - worker._execute_entity_batch(request, stub, None) - - if response is None: - raise Exception("Entity execution did not produce a response.") - # The Python worker returns the input as type "json", so double-encoding is necessary - return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' + return DurableFunctionsWorker()._execute_entity_batch(entity_func, context) handle.entity_function = entity_func # type: ignore @@ -157,8 +99,7 @@ def stub_complete(stub_response: EntityBatchResult): return decorator - def _add_rich_client(self, fb, parameter_name, - client_constructor): + def _add_rich_client(self, fb, parameter_name, client_constructor): # Obtain user-code and force type annotation on the client-binding parameter to be `str`. # This ensures a passing type-check of that specific parameter, # circumventing a limitation of the worker in type-checking rich DF Client objects. diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py index 30dc6ff5..93f3545c 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py @@ -28,6 +28,7 @@ def get_binding_name() -> str: def __init__(self, name: str, orchestration: Optional[str] = None, + durable_requires_grpc=True, ) -> None: self.orchestration = orchestration super().__init__(name=name) @@ -53,6 +54,7 @@ def get_binding_name() -> str: def __init__(self, name: str, activity: Optional[str] = None, + durable_requires_grpc=True, ) -> None: self.activity = activity super().__init__(name=name) @@ -78,6 +80,7 @@ def get_binding_name() -> str: def __init__(self, name: str, entity_name: Optional[str] = None, + durable_requires_grpc=True, ) -> None: self.entity_name = entity_name super().__init__(name=name) @@ -103,7 +106,8 @@ def get_binding_name() -> str: def __init__(self, name: str, task_hub: Optional[str] = None, - connection_name: Optional[str] = None + connection_name: Optional[str] = None, + durable_requires_grpc=True, ) -> None: self.task_hub = task_hub self.connection_name = connection_name diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py index 1fb2a7cf..9d470c6c 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py @@ -2,7 +2,20 @@ class HttpManagementPayload: + """A class representing the HTTP management payload for a Durable Function orchestration instance. + + Contains URLs for managing the instance, such as querying status, + sending events, terminating, restarting, etc. + """ + def __init__(self, instance_id: str, instance_status_url: str, required_query_string_parameters: str): + """Initializes the HttpManagementPayload with the necessary URLs. + + Args: + instance_id (str): The ID of the Durable Function instance. + instance_status_url (str): The base URL for the instance status. + required_query_string_parameters (str): The required URL parameters provided by the Durable extension. + """ self.urls = { 'id': instance_id, 'purgeHistoryDeleteUri': instance_status_url + "?" + required_query_string_parameters, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py index 8b4aca30..540f3759 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -1,15 +1,22 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import base64 from threading import Event +from typing import Optional +from durabletask.internal.orchestrator_service_pb2 import EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse from durabletask.worker import _Registry, ConcurrencyOptions from durabletask.internal import shared from durabletask.worker import TaskHubGrpcWorker +from durabletask.azurefunctions.internal.azurefunctions_null_stub import AzureFunctionsNullStub # Worker class used for Durable Task Scheduler (DTS) class DurableFunctionsWorker(TaskHubGrpcWorker): - """TODO: Docs + """A worker that can execute orchestrator and entity functions in the context of Azure Functions. + + Used internally by the Durable Functions Python SDK, and should not be visible to functionapps directly. + See TaskHubGrpcWorker for base class documentation. """ def __init__(self): @@ -27,6 +34,60 @@ def __init__(self): self._interceptors = None def add_named_orchestrator(self, name: str, func): - """TODO: Docs - """ self._registry.add_named_orchestrator(name, func) + + def _execute_orchestrator(self, func, context) -> str: + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + orchestration_context = context_body + request = OrchestratorRequest() + request.ParseFromString(base64.b64decode(orchestration_context)) + stub = AzureFunctionsNullStub() + response: Optional[OrchestratorResponse] = None + + def stub_complete(stub_response): + nonlocal response + response = stub_response + stub.CompleteOrchestratorTask = stub_complete + execution_started_events = [] + for e in request.pastEvents: + if e.HasField("executionStarted"): + execution_started_events.append(e) + for e in request.newEvents: + if e.HasField("executionStarted"): + execution_started_events.append(e) + if len(execution_started_events) == 0: + raise Exception("No ExecutionStarted event found in orchestration request.") + + function_name = execution_started_events[-1].executionStarted.name + self.add_named_orchestrator(function_name, func) + super()._execute_orchestrator(request, stub, None) + + if response is None: + raise Exception("Orchestrator execution did not produce a response.") + # The Python worker returns the input as type "json", so double-encoding is necessary + return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' + + def _execute_entity_batch(self, func, context) -> str: + context_body = getattr(context, "body", None) + if context_body is None: + context_body = context + orchestration_context = context_body + request = EntityBatchRequest() + request.ParseFromString(base64.b64decode(orchestration_context)) + stub = AzureFunctionsNullStub() + response: Optional[EntityBatchResult] = None + + def stub_complete(stub_response: EntityBatchResult): + nonlocal response + response = stub_response + stub.CompleteEntityTask = stub_complete + + self.add_entity(func) + super()._execute_entity_batch(request, stub, None) + + if response is None: + raise Exception("Entity execution did not produce a response.") + # The Python worker returns the input as type "json", so double-encoding is necessary + return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' diff --git a/durabletask-azurefunctions/pyproject.toml b/durabletask-azurefunctions/pyproject.toml index dfb02eb8..8780b01d 100644 --- a/durabletask-azurefunctions/pyproject.toml +++ b/durabletask-azurefunctions/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "durabletask.azurefunctions" -version = "0.1.0" +version = "0.0.1dev0" description = "Durable Task Python SDK provider implementation for Durable Azure Functions" keywords = [ "durable", diff --git a/durabletask/internal/helpers.py b/durabletask/internal/helpers.py index f1ca8dfa..612915ca 100644 --- a/durabletask/internal/helpers.py +++ b/durabletask/internal/helpers.py @@ -4,7 +4,6 @@ import traceback from datetime import datetime from typing import Optional -import uuid from google.protobuf import timestamp_pb2, wrappers_pb2 @@ -197,8 +196,11 @@ def new_schedule_task_action(id: int, name: str, encoded_input: Optional[str], )) -def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]): - request_id = str(uuid.uuid4()) +def new_call_entity_action(id: int, + parent_instance_id: str, + entity_id: EntityInstanceId, + operation: str, encoded_input: Optional[str], + request_id: str): return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationCalled=pb.EntityOperationCalledEvent( requestId=request_id, operation=operation, @@ -210,8 +212,11 @@ def new_call_entity_action(id: int, parent_instance_id: str, entity_id: EntityIn ))) -def new_signal_entity_action(id: int, entity_id: EntityInstanceId, operation: str, encoded_input: Optional[str]): - request_id = str(uuid.uuid4()) +def new_signal_entity_action(id: int, + entity_id: EntityInstanceId, + operation: str, + encoded_input: Optional[str], + request_id: str): return pb.OrchestratorAction(id=id, sendEntityMessage=pb.SendEntityMessageAction(entityOperationSignaled=pb.EntityOperationSignaledEvent( requestId=request_id, operation=operation, diff --git a/durabletask/task.py b/durabletask/task.py index 35708388..2f763bc4 100644 --- a/durabletask/task.py +++ b/durabletask/task.py @@ -258,6 +258,22 @@ def continue_as_new(self, new_input: Any, *, save_events: bool = False) -> None: """ pass + @abstractmethod + def new_uuid(self) -> str: + """Create a new UUID that is safe for replay within an orchestration or operation. + + The default implementation of this method creates a name-based UUID + using the algorithm from RFC 4122 §4.3. The name input used to generate + this value is a combination of the orchestration instance ID and an + internally managed sequence number. + + Returns + ------- + str + New UUID that is safe for replay within an orchestration or operation. + """ + pass + @abstractmethod def _exit_critical_section(self) -> None: pass diff --git a/durabletask/worker.py b/durabletask/worker.py index 20657444..3ae37845 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -35,6 +35,7 @@ TInput = TypeVar("TInput") TOutput = TypeVar("TOutput") +DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ' class ConcurrencyOptions: @@ -797,7 +798,7 @@ def _execute_entity_batch( stub.CompleteEntityTask(batch_result) except Exception as ex: self._logger.exception( - f"Failed to deliver entity response for '{entity_instance_id}' of orchestration ID '{instance_id}' to sidecar: {ex}" + f"Failed to deliver entity response for orchestration ID '{instance_id}' to sidecar: {ex}" ) # TODO: Reset context @@ -830,10 +831,11 @@ def __init__(self, instance_id: str, registry: _Registry): self._pending_actions: dict[int, pb.OrchestratorAction] = {} self._pending_tasks: dict[int, task.CompletableTask] = {} # Maps entity ID to task ID - self._entity_task_id_map: dict[str, tuple[EntityInstanceId, int]] = {} + self._entity_task_id_map: dict[str, tuple[EntityInstanceId, int, Optional[str]]] = {} # Maps criticalSectionId to task ID self._entity_lock_id_map: dict[str, int] = {} self._sequence_number = 0 + self._new_uuid_counter = 0 self._current_utc_datetime = datetime(1000, 1, 1) self._instance_id = instance_id self._registry = registry @@ -1041,14 +1043,14 @@ def call_activity( def call_entity( self, - entity_id: EntityInstanceId, + entity: EntityInstanceId, operation: str, input: Optional[TInput] = None, ) -> task.Task: id = self.next_sequence_number() self.call_entity_function_helper( - id, entity_id, operation, input=input + id, entity, operation, input=input ) return self._pending_tasks.get(id, task.CompletableTask()) @@ -1056,13 +1058,13 @@ def call_entity( def signal_entity( self, entity_id: EntityInstanceId, - operation: str, + operation_name: str, input: Optional[TInput] = None ) -> None: id = self.next_sequence_number() self.signal_entity_function_helper( - id, entity_id, operation, input + id, entity_id, operation_name, input ) def lock_entities(self, entities: list[EntityInstanceId]) -> task.Task[EntityLock]: @@ -1168,7 +1170,12 @@ def call_entity_function_helper( raise RuntimeError(error_message) encoded_input = shared.to_json(input) if input is not None else None - action = ph.new_call_entity_action(id, self.instance_id, entity_id, operation, encoded_input) + action = ph.new_call_entity_action(id, + self.instance_id, + entity_id, + operation, + encoded_input, + self.new_uuid()) self._pending_actions[id] = action fn_task = task.CompletableTask() @@ -1191,7 +1198,7 @@ def signal_entity_function_helper( encoded_input = shared.to_json(input) if input is not None else None - action = ph.new_signal_entity_action(id, entity_id, operation, encoded_input) + action = ph.new_signal_entity_action(id, entity_id, operation, encoded_input, self.new_uuid()) self._pending_actions[id] = action def lock_entities_function_helper(self, id: int, entities: list[EntityInstanceId]) -> None: @@ -1202,7 +1209,7 @@ def lock_entities_function_helper(self, id: int, entities: list[EntityInstanceId if not transition_valid: raise RuntimeError(error_message) - critical_section_id = str(uuid.uuid4()) + critical_section_id = self.new_uuid() request, target = self._entity_context.emit_acquire_message(critical_section_id, entities) @@ -1254,6 +1261,17 @@ def continue_as_new(self, new_input, *, save_events: bool = False) -> None: self.set_continued_as_new(new_input, save_events) + def new_uuid(self) -> str: + URL_NAMESPACE: str = "9e952958-5e33-4daf-827f-2fa12937b875" + + uuid_name_value = \ + f"{self._instance_id}" \ + f"_{self.current_utc_datetime.strftime(DATETIME_STRING_FORMAT)}" \ + f"_{self._new_uuid_counter}" + self._new_uuid_counter += 1 + namespace_uuid = uuid.uuid5(uuid.NAMESPACE_OID, URL_NAMESPACE) + return str(uuid.uuid5(namespace_uuid, uuid_name_value)) + class ExecutionResults: actions: list[pb.OrchestratorAction] @@ -1596,7 +1614,7 @@ def process_event( if event.eventRaised.name in ctx._entity_task_id_map: # This eventRaised represents the result of an entity operation after being translated to the old # entity protocol by the Durable WebJobs extension - entity_id, task_id = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None)) + entity_id, task_id, action_type = ctx._entity_task_id_map.get(event.eventRaised.name, (None, None, None)) if entity_id is None: raise RuntimeError(f"Could not retrieve entity ID for entity-related eventRaised with ID '{event.eventId}'") if task_id is None: @@ -1608,9 +1626,18 @@ def process_event( if not ph.is_empty(event.eventRaised.input): # TODO: Investigate why the event result is wrapped in a dict with "result" key result = shared.from_json(event.eventRaised.input.value)["result"] - ctx._entity_context.recover_lock_after_call(entity_id) - entity_task.complete(result) - ctx.resume() + if action_type == "entityOperationCalled": + ctx._entity_context.recover_lock_after_call(entity_id) + entity_task.complete(result) + ctx.resume() + elif action_type == "entityLockRequested": + ctx._entity_context.complete_acquire(event.eventRaised.name) + entity_task.complete(EntityLock(ctx)) + ctx.resume() + else: + raise RuntimeError(f"Unknown action type '{action_type}' for entity-related eventRaised " + f"with ID '{event.eventId}'") + else: # event names are case-insensitive event_name = event.eventRaised.name.casefold() @@ -1681,7 +1708,7 @@ def process_event( entity_id = EntityInstanceId.parse(event.entityOperationCalled.targetInstanceId.value) if not entity_id: raise RuntimeError(f"Could not parse entity ID from targetInstanceId '{event.entityOperationCalled.targetInstanceId.value}'") - ctx._entity_task_id_map[event.entityOperationCalled.requestId] = (entity_id, entity_call_id) + ctx._entity_task_id_map[event.entityOperationCalled.requestId] = (entity_id, entity_call_id, None) elif event.HasField("entityOperationSignaled"): # This history event confirms that the entity signal was successfully scheduled. # Remove the entityOperationSignaled event from the pending action list so we don't schedule it @@ -1742,7 +1769,7 @@ def process_event( ctx.resume() elif event.HasField("entityOperationCompleted"): request_id = event.entityOperationCompleted.requestId - entity_id, task_id = ctx._entity_task_id_map.pop(request_id, (None, None)) + entity_id, task_id, _ = ctx._entity_task_id_map.pop(request_id, (None, None, None)) if not entity_id: raise RuntimeError(f"Could not parse entity ID from request ID '{request_id}'") if not task_id: @@ -1770,14 +1797,20 @@ def process_event( pass elif event.HasField("eventSent"): # Check if this eventSent corresponds to an entity operation call after being translated to the old - # entity protocol by the Durable WebJobs extension. If so, treat this message similarly to + # entity protocol by the Durable WebJobs extension. If so, treat this message similarly to # entityOperationCalled and remove the pending action. Also store the entity id and event id for later action = ctx._pending_actions.pop(event.eventId, None) - if action and action.HasField("sendEntityMessage") and action.sendEntityMessage.HasField("entityOperationCalled"): + if action and action.HasField("sendEntityMessage"): + if action.sendEntityMessage.HasField("entityOperationCalled"): + action_type = "entityOperationCalled" + elif action.sendEntityMessage.HasField("entityLockRequested"): + action_type = "entityLockRequested" + else: + return + entity_id = EntityInstanceId.parse(event.eventSent.instanceId) event_id = json.loads(event.eventSent.input.value)["id"] - ctx._entity_task_id_map[event_id] = (entity_id, event.eventId) - return + ctx._entity_task_id_map[event_id] = (entity_id, event.eventId, action_type) else: eventType = event.WhichOneof("eventType") raise task.OrchestrationStateError( From cc005ae4f9022a79974614f850c33f7f15156334 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 4 Dec 2025 12:06:23 -0700 Subject: [PATCH 09/33] Bump durabletask version, fix metadata --- .../workflows/durabletask-azurefunctions.yml | 126 ++++++++++++++++++ .../azurefunctions/decorators/metadata.py | 4 + durabletask-azurefunctions/pyproject.toml | 2 +- pyproject.toml | 2 +- 4 files changed, 132 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/durabletask-azurefunctions.yml diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml new file mode 100644 index 00000000..ba800944 --- /dev/null +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -0,0 +1,126 @@ +name: Durable Task Scheduler SDK (durabletask-azurefunctions) + +on: + push: + branches: + - "main" + tags: + - "azurefunctions-v*" # Only run for tags starting with "azurefunctions-v" + pull_request: + branches: + - "main" + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: 3.14 + - name: Install dependencies + working-directory: durabletask-azurefunctions + run: | + python -m pip install --upgrade pip + pip install setuptools wheel tox + pip install flake8 + - name: Run flake8 Linter + working-directory: durabletask-azurefunctions + run: flake8 . + - name: Run flake8 Linter + working-directory: tests/durabletask-azurefunctions + run: flake8 . + + run-docker-tests: + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] + env: + EMULATOR_VERSION: "latest" + needs: lint + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Pull Docker image + run: docker pull mcr.microsoft.com/dts/dts-emulator:$EMULATOR_VERSION + + - name: Run Docker container + run: | + docker run --name dtsemulator -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:$EMULATOR_VERSION + + - name: Wait for container to be ready + run: sleep 10 # Adjust if your service needs more time to start + + - name: Set environment variables + run: | + echo "TASKHUB=default" >> $GITHUB_ENV + echo "ENDPOINT=http://localhost:8080" >> $GITHUB_ENV + + - name: Install durabletask dependencies + run: | + python -m pip install --upgrade pip + pip install flake8 pytest + pip install -r requirements.txt + + - name: Install durabletask-azurefunctions dependencies + working-directory: examples + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Install durabletask-azurefunctions locally + working-directory: durabletask-azurefunctions + run: | + pip install . --no-deps --force-reinstall + + - name: Install durabletask locally + run: | + pip install . --no-deps --force-reinstall + + - name: Run the tests + working-directory: tests/durabletask-azurefunctions + run: | + pytest -m "dts" --verbose + + publish: + if: startsWith(github.ref, 'refs/tags/azurefunctions-v') # Only run if a matching tag is pushed + needs: run-docker-tests + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" # Adjust Python version as needed + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Build package from directory durabletask-azurefunctions + working-directory: durabletask-azurefunctions + run: | + python -m build + + - name: Check package + working-directory: durabletask-azurefunctions + run: | + twine check dist/* + + - name: Publish package to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets + working-directory: durabletask-azurefunctions + run: | + twine upload dist/* \ No newline at end of file diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py index 93f3545c..21cd7f42 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py @@ -31,6 +31,7 @@ def __init__(self, durable_requires_grpc=True, ) -> None: self.orchestration = orchestration + self.durable_requires_grpc = durable_requires_grpc super().__init__(name=name) @@ -57,6 +58,7 @@ def __init__(self, durable_requires_grpc=True, ) -> None: self.activity = activity + self.durable_requires_grpc = durable_requires_grpc super().__init__(name=name) @@ -83,6 +85,7 @@ def __init__(self, durable_requires_grpc=True, ) -> None: self.entity_name = entity_name + self.durable_requires_grpc = durable_requires_grpc super().__init__(name=name) @@ -111,4 +114,5 @@ def __init__(self, ) -> None: self.task_hub = task_hub self.connection_name = connection_name + self.durable_requires_grpc = durable_requires_grpc super().__init__(name=name) diff --git a/durabletask-azurefunctions/pyproject.toml b/durabletask-azurefunctions/pyproject.toml index 8780b01d..b1e72e5a 100644 --- a/durabletask-azurefunctions/pyproject.toml +++ b/durabletask-azurefunctions/pyproject.toml @@ -27,7 +27,7 @@ requires-python = ">=3.9" license = {file = "LICENSE"} readme = "README.md" dependencies = [ - "durabletask>=0.5.0", + "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", "azure-functions>=1.11.0" ] diff --git a/pyproject.toml b/pyproject.toml index 547eb7ad..958981e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,7 @@ build-backend = "setuptools.build_meta" [project] name = "durabletask" -version = "1.0.0" +version = "1.2.0dev0" description = "A Durable Task Client SDK for Python" keywords = [ "durable", From bf6d6f2bcd6309b32a8bb10b4940f8a3092151c8 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 12:14:02 -0700 Subject: [PATCH 10/33] Use Protocol for stubs --- .../internal/azurefunctions_null_stub.py | 58 +++++++++--------- .../ProtoTaskHubSidecarServiceStub.py | 60 +++++++++---------- 2 files changed, 55 insertions(+), 63 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py index 47a0ce7e..75a48a0a 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py @@ -1,38 +1,34 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub +from durabletask.internal.proto_task_hub_sidecar_service_stub import ProtoTaskHubSidecarServiceStub class AzureFunctionsNullStub(ProtoTaskHubSidecarServiceStub): """A task hub sidecar stub class that implements all methods as no-ops.""" - - def __init__(self): - """Constructor. - """ - self.Hello = lambda *args, **kwargs: None - self.StartInstance = lambda *args, **kwargs: None - self.GetInstance = lambda *args, **kwargs: None - self.RewindInstance = lambda *args, **kwargs: None - self.WaitForInstanceStart = lambda *args, **kwargs: None - self.WaitForInstanceCompletion = lambda *args, **kwargs: None - self.RaiseEvent = lambda *args, **kwargs: None - self.TerminateInstance = lambda *args, **kwargs: None - self.SuspendInstance = lambda *args, **kwargs: None - self.ResumeInstance = lambda *args, **kwargs: None - self.QueryInstances = lambda *args, **kwargs: None - self.PurgeInstances = lambda *args, **kwargs: None - self.GetWorkItems = lambda *args, **kwargs: None - self.CompleteActivityTask = lambda *args, **kwargs: None - self.CompleteOrchestratorTask = lambda *args, **kwargs: None - self.CompleteEntityTask = lambda *args, **kwargs: None - self.StreamInstanceHistory = lambda *args, **kwargs: None - self.CreateTaskHub = lambda *args, **kwargs: None - self.DeleteTaskHub = lambda *args, **kwargs: None - self.SignalEntity = lambda *args, **kwargs: None - self.GetEntity = lambda *args, **kwargs: None - self.QueryEntities = lambda *args, **kwargs: None - self.CleanEntityStorage = lambda *args, **kwargs: None - self.AbandonTaskActivityWorkItem = lambda *args, **kwargs: None - self.AbandonTaskOrchestratorWorkItem = lambda *args, **kwargs: None - self.AbandonTaskEntityWorkItem = lambda *args, **kwargs: None + Hello = lambda *args, **kwargs: None + StartInstance = lambda *args, **kwargs: None + GetInstance = lambda *args, **kwargs: None + RewindInstance = lambda *args, **kwargs: None + WaitForInstanceStart = lambda *args, **kwargs: None + WaitForInstanceCompletion = lambda *args, **kwargs: None + RaiseEvent = lambda *args, **kwargs: None + TerminateInstance = lambda *args, **kwargs: None + SuspendInstance = lambda *args, **kwargs: None + ResumeInstance = lambda *args, **kwargs: None + QueryInstances = lambda *args, **kwargs: None + PurgeInstances = lambda *args, **kwargs: None + GetWorkItems = lambda *args, **kwargs: None + CompleteActivityTask = lambda *args, **kwargs: None + CompleteOrchestratorTask = lambda *args, **kwargs: None + CompleteEntityTask = lambda *args, **kwargs: None + StreamInstanceHistory = lambda *args, **kwargs: None + CreateTaskHub = lambda *args, **kwargs: None + DeleteTaskHub = lambda *args, **kwargs: None + SignalEntity = lambda *args, **kwargs: None + GetEntity = lambda *args, **kwargs: None + QueryEntities = lambda *args, **kwargs: None + CleanEntityStorage = lambda *args, **kwargs: None + AbandonTaskActivityWorkItem = lambda *args, **kwargs: None + AbandonTaskOrchestratorWorkItem = lambda *args, **kwargs: None + AbandonTaskEntityWorkItem = lambda *args, **kwargs: None diff --git a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py index 7ccfd589..f91a15c4 100644 --- a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py +++ b/durabletask/internal/ProtoTaskHubSidecarServiceStub.py @@ -1,38 +1,34 @@ -from typing import Callable +from typing import Any, Callable, Protocol -class ProtoTaskHubSidecarServiceStub(object): +class ProtoTaskHubSidecarServiceStub(Protocol): """A stub class roughly matching the TaskHubSidecarServiceStub generated from the .proto file. Used by Azure Functions during orchestration and entity executions to inject custom behavior, as no real sidecar stub is available. """ - - def __init__(self): - """Constructor. - """ - self.Hello: Callable[..., None] - self.StartInstance: Callable[..., None] - self.GetInstance: Callable[..., None] - self.RewindInstance: Callable[..., None] - self.WaitForInstanceStart: Callable[..., None] - self.WaitForInstanceCompletion: Callable[..., None] - self.RaiseEvent: Callable[..., None] - self.TerminateInstance: Callable[..., None] - self.SuspendInstance: Callable[..., None] - self.ResumeInstance: Callable[..., None] - self.QueryInstances: Callable[..., None] - self.PurgeInstances: Callable[..., None] - self.GetWorkItems: Callable[..., None] - self.CompleteActivityTask: Callable[..., None] - self.CompleteOrchestratorTask: Callable[..., None] - self.CompleteEntityTask: Callable[..., None] - self.StreamInstanceHistory: Callable[..., None] - self.CreateTaskHub: Callable[..., None] - self.DeleteTaskHub: Callable[..., None] - self.SignalEntity: Callable[..., None] - self.GetEntity: Callable[..., None] - self.QueryEntities: Callable[..., None] - self.CleanEntityStorage: Callable[..., None] - self.AbandonTaskActivityWorkItem: Callable[..., None] - self.AbandonTaskOrchestratorWorkItem: Callable[..., None] - self.AbandonTaskEntityWorkItem: Callable[..., None] + Hello: Callable[..., Any] + StartInstance: Callable[..., Any] + GetInstance: Callable[..., Any] + RewindInstance: Callable[..., Any] + WaitForInstanceStart: Callable[..., Any] + WaitForInstanceCompletion: Callable[..., Any] + RaiseEvent: Callable[..., Any] + TerminateInstance: Callable[..., Any] + SuspendInstance: Callable[..., Any] + ResumeInstance: Callable[..., Any] + QueryInstances: Callable[..., Any] + PurgeInstances: Callable[..., Any] + GetWorkItems: Callable[..., Any] + CompleteActivityTask: Callable[..., Any] + CompleteOrchestratorTask: Callable[..., Any] + CompleteEntityTask: Callable[..., Any] + StreamInstanceHistory: Callable[..., Any] + CreateTaskHub: Callable[..., Any] + DeleteTaskHub: Callable[..., Any] + SignalEntity: Callable[..., Any] + GetEntity: Callable[..., Any] + QueryEntities: Callable[..., Any] + CleanEntityStorage: Callable[..., Any] + AbandonTaskActivityWorkItem: Callable[..., Any] + AbandonTaskOrchestratorWorkItem: Callable[..., Any] + AbandonTaskEntityWorkItem: Callable[..., Any] From 1176d0325f6b67906879ba51a2623c839493354e Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 13:08:17 -0700 Subject: [PATCH 11/33] Update to new workflow pattern --- .../durabletask-azurefunctions-dev.yml | 52 +++++++++++++++++++ ...urabletask-azurefunctions-experimental.yml | 50 ++++++++++++++++++ .../workflows/durabletask-azurefunctions.yml | 2 +- 3 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/durabletask-azurefunctions-dev.yml create mode 100644 .github/workflows/durabletask-azurefunctions-experimental.yml diff --git a/.github/workflows/durabletask-azurefunctions-dev.yml b/.github/workflows/durabletask-azurefunctions-dev.yml new file mode 100644 index 00000000..fa7b720f --- /dev/null +++ b/.github/workflows/durabletask-azurefunctions-dev.yml @@ -0,0 +1,52 @@ +name: Durable Task Scheduler SDK (durabletask-azurefunctions) Dev Release + +on: + workflow_run: + workflows: ["Durable Task Scheduler SDK (durabletask-azurefunctions)"] + types: + - completed + branches: + - main + +jobs: + publish-dev: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" # Adjust Python version as needed + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Append dev to version in pyproject.toml + working-directory: durabletask-azurefunctions + run: | + sed -i 's/^version = "\(.*\)"/version = "\1.dev${{ github.run_number }}"/' pyproject.toml + + - name: Build package from directory durabletask-azurefunctions + working-directory: durabletask-azurefunctions + run: | + python -m build + + - name: Check package + working-directory: durabletask-azurefunctions + run: | + twine check dist/* + + - name: Publish package to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets + working-directory: durabletask-azurefunctions + run: | + twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions-experimental.yml b/.github/workflows/durabletask-azurefunctions-experimental.yml new file mode 100644 index 00000000..06b663de --- /dev/null +++ b/.github/workflows/durabletask-azurefunctions-experimental.yml @@ -0,0 +1,50 @@ +name: Durable Task Scheduler SDK (durabletask-azurefunctions) Experimental Release + +on: + push: + branches-ignore: + - main + - release/* + +jobs: + publish-experimental: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Extract version from tag + run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.14" # Adjust Python version as needed + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + + - name: Change the version in pyproject.toml to 0.0.0dev{github.run_number} + working-directory: durabletask-azurefunctions + run: | + sed -i 's/^version = ".*"/version = "0.0.0.dev${{ github.run_number }}"/' pyproject.toml + + - name: Build package from directory durabletask-azurefunctions + working-directory: durabletask-azurefunctions + run: | + python -m build + + - name: Check package + working-directory: durabletask-azurefunctions + run: | + twine check dist/* + + - name: Publish package to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets + working-directory: durabletask-azurefunctions + run: | + twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml index ba800944..2fc74540 100644 --- a/.github/workflows/durabletask-azurefunctions.yml +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -86,7 +86,7 @@ jobs: run: | pytest -m "dts" --verbose - publish: + publish-release: if: startsWith(github.ref, 'refs/tags/azurefunctions-v') # Only run if a matching tag is pushed needs: run-docker-tests runs-on: ubuntu-latest From 7bf763af9d09eddb58c4ceafa92416dc6fdb0177 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 13:35:45 -0700 Subject: [PATCH 12/33] Rename stub file --- ...decarServiceStub.py => proto_task_hub_sidecar_service_stub.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename durabletask/internal/{ProtoTaskHubSidecarServiceStub.py => proto_task_hub_sidecar_service_stub.py} (100%) diff --git a/durabletask/internal/ProtoTaskHubSidecarServiceStub.py b/durabletask/internal/proto_task_hub_sidecar_service_stub.py similarity index 100% rename from durabletask/internal/ProtoTaskHubSidecarServiceStub.py rename to durabletask/internal/proto_task_hub_sidecar_service_stub.py From 811653e024b4f7a5e670047f23bdf077300bdf3e Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 13:39:34 -0700 Subject: [PATCH 13/33] Fix import --- durabletask/worker.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/durabletask/worker.py b/durabletask/worker.py index 3ae37845..cd1f899c 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -20,7 +20,7 @@ from google.protobuf import empty_pb2 from durabletask.internal import helpers -from durabletask.internal.ProtoTaskHubSidecarServiceStub import ProtoTaskHubSidecarServiceStub +from durabletask.internal.proto_task_hub_sidecar_service_stub import ProtoTaskHubSidecarServiceStub from durabletask.internal.entity_state_shim import StateShim from durabletask.internal.helpers import new_timestamp from durabletask.entities import DurableEntity, EntityLock, EntityInstanceId, EntityContext From 827d2013d08c4ec88781af25f99a77bdb1033ac8 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 5 Dec 2025 14:29:52 -0700 Subject: [PATCH 14/33] Experimental dependency revision --- .github/workflows/durabletask-azurefunctions-experimental.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/durabletask-azurefunctions-experimental.yml b/.github/workflows/durabletask-azurefunctions-experimental.yml index 06b663de..49b8c250 100644 --- a/.github/workflows/durabletask-azurefunctions-experimental.yml +++ b/.github/workflows/durabletask-azurefunctions-experimental.yml @@ -30,6 +30,7 @@ jobs: working-directory: durabletask-azurefunctions run: | sed -i 's/^version = ".*"/version = "0.0.0.dev${{ github.run_number }}"/' pyproject.toml + sed -i 's/"durabletask>=.*"/"durabletask>=0.0.0dev1"/' pyproject.toml - name: Build package from directory durabletask-azurefunctions working-directory: durabletask-azurefunctions From fde02c501a22d0e970c161ab2daf69d7a91a8e73 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 11 Dec 2025 10:38:59 -0700 Subject: [PATCH 15/33] Update to match changes in functions SDK --- .../azurefunctions/decorators/durable_app.py | 11 +++++++---- .../durabletask/azurefunctions/worker.py | 11 ++++++----- durabletask-azurefunctions/pyproject.toml | 2 +- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index 15a13e59..e4e249ff 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -3,6 +3,8 @@ from functools import wraps +from durabletask import task + from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from typing import Callable, Optional @@ -54,7 +56,7 @@ def _configure_orchestrator_callable(self, wrap) -> Callable: The function to construct an Orchestrator class from the user-defined Function, wrapped by the next decorator in the sequence. """ - def decorator(orchestrator_func): + def decorator(orchestrator_func: task.Orchestrator): # Construct an orchestrator based on the end-user code def handle(context) -> str: @@ -82,7 +84,7 @@ def _configure_entity_callable(self, wrap) -> Callable: The function to construct an Entity class from the user-defined Function, wrapped by the next decorator in the sequence. """ - def decorator(entity_func): + def decorator(entity_func: task.Entity): # Construct an orchestrator based on the end-user code # TODO: Because this handle method is the one actually exposed to the Functions SDK decorator, @@ -177,7 +179,8 @@ def decorator(): return wrap - def entity_trigger(self, context_name: str, + def entity_trigger(self, + context_name: str, entity_name: Optional[str] = None): """Register an Entity Function. @@ -228,7 +231,7 @@ def durable_client_input(self, @self._configure_function_builder def wrap(fb): def decorator(): - self._add_rich_client(fb, client_name, DurableFunctionsClient) + # self._add_rich_client(fb, client_name, DurableFunctionsClient) fb.add_binding( binding=DurableClient(name=client_name, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py index 540f3759..5cef7f4e 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/worker.py @@ -4,6 +4,7 @@ import base64 from threading import Event from typing import Optional +from durabletask import task from durabletask.internal.orchestrator_service_pb2 import EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse from durabletask.worker import _Registry, ConcurrencyOptions from durabletask.internal import shared @@ -33,10 +34,10 @@ def __init__(self): self._interceptors = None - def add_named_orchestrator(self, name: str, func): + def add_named_orchestrator(self, name: str, func: task.Orchestrator): self._registry.add_named_orchestrator(name, func) - def _execute_orchestrator(self, func, context) -> str: + def _execute_orchestrator(self, func: task.Orchestrator, context) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context @@ -67,9 +68,9 @@ def stub_complete(stub_response): if response is None: raise Exception("Orchestrator execution did not produce a response.") # The Python worker returns the input as type "json", so double-encoding is necessary - return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' + return base64.b64encode(response.SerializeToString()).decode('utf-8') - def _execute_entity_batch(self, func, context) -> str: + def _execute_entity_batch(self, func: task.Entity, context) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context @@ -90,4 +91,4 @@ def stub_complete(stub_response: EntityBatchResult): if response is None: raise Exception("Entity execution did not produce a response.") # The Python worker returns the input as type "json", so double-encoding is necessary - return '"' + base64.b64encode(response.SerializeToString()).decode('utf-8') + '"' + return base64.b64encode(response.SerializeToString()).decode('utf-8') diff --git a/durabletask-azurefunctions/pyproject.toml b/durabletask-azurefunctions/pyproject.toml index b1e72e5a..79704f0e 100644 --- a/durabletask-azurefunctions/pyproject.toml +++ b/durabletask-azurefunctions/pyproject.toml @@ -29,7 +29,7 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=1.11.0" + "azure-functions>=1.25.0b3.dev1" ] [project.urls] From 2df96dccb55ce374fe8527bfd862a51f07873b68 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 12 Dec 2025 12:26:41 -0700 Subject: [PATCH 16/33] Merge issue fix --- durabletask/worker.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/durabletask/worker.py b/durabletask/worker.py index 7fd2f6d9..838d4abe 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -835,6 +835,8 @@ def __init__(self, instance_id: str, registry: _Registry): # Maps entity ID to task ID self._entity_task_id_map: dict[str, tuple[EntityInstanceId, int]] = {} self._entity_lock_task_id_map: dict[str, tuple[EntityInstanceId, int]] = {} + # Maps criticalSectionId to task ID + self._entity_lock_id_map: dict[str, int] = {} self._sequence_number = 0 self._new_uuid_counter = 0 self._current_utc_datetime = datetime(1000, 1, 1) @@ -1171,6 +1173,7 @@ def call_entity_function_helper( raise RuntimeError(error_message) encoded_input = shared.to_json(input) if input is not None else None + action = ph.new_call_entity_action(id, self.instance_id, entity_id, operation, encoded_input, self.new_uuid()) self._pending_actions[id] = action @@ -1684,7 +1687,7 @@ def process_event( entity_id = EntityInstanceId.parse(event.entityOperationCalled.targetInstanceId.value) except ValueError: raise RuntimeError(f"Could not parse entity ID from targetInstanceId '{event.entityOperationCalled.targetInstanceId.value}'") - ctx._entity_task_id_map[event.entityOperationCalled.requestId] = (entity_id, entity_call_id, None) + ctx._entity_task_id_map[event.entityOperationCalled.requestId] = (entity_id, entity_call_id) elif event.HasField("entityOperationSignaled"): # This history event confirms that the entity signal was successfully scheduled. # Remove the entityOperationSignaled event from the pending action list so we don't schedule it @@ -1745,7 +1748,7 @@ def process_event( ctx.resume() elif event.HasField("entityOperationCompleted"): request_id = event.entityOperationCompleted.requestId - entity_id, task_id, _ = ctx._entity_task_id_map.pop(request_id, (None, None, None)) + entity_id, task_id = ctx._entity_task_id_map.pop(request_id, (None, None)) if not entity_id: raise RuntimeError(f"Could not parse entity ID from request ID '{request_id}'") if not task_id: From eac9efda2549a05f6ee167724877f623f83a390e Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 6 Jan 2026 14:50:20 -0700 Subject: [PATCH 17/33] Various --- .../durabletask/azurefunctions/__init__.py | 4 ++++ .../durabletask/azurefunctions/client.py | 2 +- .../azurefunctions/decorators/durable_app.py | 2 -- .../azurefunctions/http/http_management_payload.py | 3 +++ .../azurefunctions/internal/functions_json.py | 10 ++++++++++ durabletask/internal/shared.py | 8 ++++++-- durabletask/worker.py | 14 +++++++++++--- 7 files changed, 35 insertions(+), 8 deletions(-) create mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py index c7680213..f34a9668 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py @@ -1,6 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# This import ensures that the replacement of the global JSON encoder/decoder +# happens as soon as the durabletask.azurefunctions package is imported. +import durabletask.azurefunctions.internal.functions_json as _ + from durabletask.azurefunctions.decorators.durable_app import Blueprint, DFApp from durabletask.azurefunctions.client import DurableFunctionsClient diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/durabletask-azurefunctions/durabletask/azurefunctions/client.py index 362ef899..181e9c39 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/client.py @@ -77,7 +77,7 @@ def create_check_status_response(self, request: func.HttpRequest, instance_id: s location_url = self._get_instance_status_url(request, instance_id) return func.HttpResponse( body=str(self._get_client_response_links(request, instance_id)), - status_code=501, + status_code=202, headers={ 'content-type': 'application/json', 'Location': location_url, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py index e4e249ff..f3f02e0a 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py @@ -231,8 +231,6 @@ def durable_client_input(self, @self._configure_function_builder def wrap(fb): def decorator(): - # self._add_rich_client(fb, client_name, DurableFunctionsClient) - fb.add_binding( binding=DurableClient(name=client_name, task_hub=task_hub, diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py index 9d470c6c..a6836844 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py +++ b/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py @@ -1,3 +1,6 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + import json diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py b/durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py new file mode 100644 index 00000000..71d2b721 --- /dev/null +++ b/durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py @@ -0,0 +1,10 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from azure.functions._durable_functions import _serialize_custom_object, _deserialize_custom_object +from durabletask.internal import shared + + +shared.to_json = lambda obj: json.dumps(obj, default=_serialize_custom_object) +shared.from_json = lambda json_str: json.loads(json_str, object_hook=_deserialize_custom_object) \ No newline at end of file diff --git a/durabletask/internal/shared.py b/durabletask/internal/shared.py index 1872ad45..298ba20c 100644 --- a/durabletask/internal/shared.py +++ b/durabletask/internal/shared.py @@ -84,11 +84,11 @@ def get_logger( def to_json(obj): - return json.dumps(obj, cls=InternalJSONEncoder) + return json.dumps(obj, cls=global_json_encoder) def from_json(json_str): - return json.loads(json_str, cls=InternalJSONDecoder) + return json.loads(json_str, cls=global_json_decoder) class InternalJSONEncoder(json.JSONEncoder): @@ -127,3 +127,7 @@ def dict_to_object(self, d: dict[str, Any]): if d.pop(AUTO_SERIALIZED, False): return SimpleNamespace(**d) return d + + +global_json_encoder: type = InternalJSONEncoder +global_json_decoder: type = InternalJSONDecoder \ No newline at end of file diff --git a/durabletask/worker.py b/durabletask/worker.py index 838d4abe..e68b3bcd 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -20,7 +20,6 @@ from google.protobuf import empty_pb2 from durabletask.internal import helpers -from durabletask.internal.proto_task_hub_sidecar_service_stub import ProtoTaskHubSidecarServiceStub from durabletask.internal.entity_state_shim import StateShim from durabletask.internal.helpers import new_timestamp from durabletask.entities import DurableEntity, EntityLock, EntityInstanceId, EntityContext @@ -800,8 +799,7 @@ def _execute_entity_batch( stub.CompleteEntityTask(batch_result) except Exception as ex: self._logger.exception( - f"Failed to deliver entity response for orchestration ID '{instance_id}' to sidecar: {ex}" - ) + f"Failed to deliver entity response for '{entity_instance_id}' of orchestration ID '{instance_id}' to sidecar: {ex}") # TODO: Reset context @@ -1825,6 +1823,16 @@ def _handle_entity_event_raised(self, if not ph.is_empty(event.eventRaised.input): # TODO: Investigate why the event result is wrapped in a dict with "result" key result = shared.from_json(event.eventRaised.input.value)["result"] + # The result here is double-encoded somewhere, so we need to decode it again. This does not happen + # with entityOperationCompleted, so it's either part of the event entity messaging protocol in Core, + # or something done by the WebJobs extension. + if result and isinstance(result, str): + try: + result = shared.from_json(result) + except Exception as ex: + self._logger.warning(f"{ctx.instance_id}: Could not deserialize entity operation result to object " + f"for entity '{entity_id}', defaulting to encoded string." + f"Decode error: {ex}") if is_lock_event: ctx._entity_context.complete_acquire(event.eventRaised.name) entity_task.complete(EntityLock(ctx)) From 20aacab6cedf7b1cd52ea05f1e46daea9fbb523c Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 6 Jan 2026 14:50:40 -0700 Subject: [PATCH 18/33] Add Functions to requirements --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index f32d3500..4907828e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ protobuf pytest pytest-cov azure-identity +azure-functions asyncio packaging \ No newline at end of file From 5df87b11338501767edd0161b09ab922fd233f06 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Fri, 30 Jan 2026 12:45:52 -0700 Subject: [PATCH 19/33] Rename to azure-functions-durable v2 --- .../CHANGELOG.md | 0 .../azure/durable_functions/__init__.py | 15 +++++ .../azure/durable_functions}/client.py | 66 +++++++++++++++---- .../azure/durable_functions}/constants.py | 0 .../durable_functions}/decorators/__init__.py | 0 .../decorators/durable_app.py | 7 +- .../durable_functions}/decorators/metadata.py | 2 +- .../azure/durable_functions}/http/__init__.py | 2 +- .../http/http_management_payload.py | 0 .../durable_functions}/internal/__init__.py | 0 .../azurefunctions_grpc_interceptor.py | 0 .../internal/azurefunctions_null_stub.py | 0 .../internal/functions_json.py | 0 .../azure/durable_functions}/worker.py | 2 +- .../pyproject.toml | 8 +-- durabletask-azurefunctions/__init__.py | 0 .../durabletask/azurefunctions/__init__.py | 11 ---- durabletask/client.py | 17 ++++- 18 files changed, 93 insertions(+), 37 deletions(-) rename {durabletask-azurefunctions => azure-functions-durable}/CHANGELOG.md (100%) create mode 100644 azure-functions-durable/azure/durable_functions/__init__.py rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/client.py (66%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/constants.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/decorators/__init__.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/decorators/durable_app.py (98%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/decorators/metadata.py (97%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/http/__init__.py (55%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/http/http_management_payload.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/internal/__init__.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/internal/azurefunctions_grpc_interceptor.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/internal/azurefunctions_null_stub.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/internal/functions_json.py (100%) rename {durabletask-azurefunctions/durabletask/azurefunctions => azure-functions-durable/azure/durable_functions}/worker.py (97%) rename {durabletask-azurefunctions => azure-functions-durable}/pyproject.toml (86%) delete mode 100644 durabletask-azurefunctions/__init__.py delete mode 100644 durabletask-azurefunctions/durabletask/azurefunctions/__init__.py diff --git a/durabletask-azurefunctions/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md similarity index 100% rename from durabletask-azurefunctions/CHANGELOG.md rename to azure-functions-durable/CHANGELOG.md diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py new file mode 100644 index 00000000..1c0b6f42 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +# This import ensures that the replacement of the global JSON encoder/decoder +# happens as soon as the durabletask.azurefunctions package is imported. +from .internal import functions_json as _ + +from .decorators.durable_app import Blueprint, DFApp +from .client import DurableFunctionsClient + +# IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable +# for version detection +version = "2.x" + +__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient", "version"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/client.py b/azure-functions-durable/azure/durable_functions/client.py similarity index 66% rename from durabletask-azurefunctions/durabletask/azurefunctions/client.py rename to azure-functions-durable/azure/durable_functions/client.py index 181e9c39..7ca31466 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -4,14 +4,13 @@ import json from datetime import timedelta -from typing import Any, Optional import azure.functions as func -from urllib.parse import urlparse, quote +from urllib.parse import urlparse, urljoin, quote -from durabletask.entities import EntityInstanceId from durabletask.client import TaskHubGrpcClient -from durabletask.azurefunctions.internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl -from durabletask.azurefunctions.http import HttpManagementPayload +from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl +from .http import HttpManagementPayload +import requests # Client class used for Durable Functions @@ -38,6 +37,31 @@ def __init__(self, client_as_string: str): This string will be provided by the Durable Functions host extension upon invocation of the client trigger. + Args: + client_as_string (str): A JSON string containing the Durable Functions client configuration. + + Raises: + json.JSONDecodeError: If the provided string is not valid JSON. + """ + self._parse_client_configuration(client_as_string) + if self.httpBaseUrl is None: + # This happens when the extension has not been configured for gRPC yet. For some reason, instead of + # the client returning with null rpcBaseUrl and httpBaseUrl, it returns rpcBaseUrl with the http url. + self.configure_extension_for_grpc() + + interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] + + # We pass in None for the metadata so we don't construct an additional interceptor in the parent class + # Since the parent class doesn't use anything metadata for anything else, we can set it as None + super().__init__( + host_address=self.rpcBaseUrl, + secure_channel=False, + metadata=None, + interceptors=interceptors) + + def _parse_client_configuration(self, client_as_string: str) -> None: + """Parses the client configuration JSON string and sets instance variables. + Args: client_as_string (str): A JSON string containing the Durable Functions client configuration. @@ -57,15 +81,31 @@ def __init__(self, client_as_string: str): self.maxGrpcMessageSizeInBytes = client.get("maxGrpcMessageSizeInBytes", 0) # TODO: convert the string value back to timedelta - annoying regex? self.grpcHttpClientTimeout = client.get("grpcHttpClientTimeout", timedelta(seconds=30)) - interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] - # We pass in None for the metadata so we don't construct an additional interceptor in the parent class - # Since the parent class doesn't use anything metadata for anything else, we can set it as None - super().__init__( - host_address=self.rpcBaseUrl, - secure_channel=False, - metadata=None, - interceptors=interceptors) + def configure_extension_for_grpc(self) -> None: + """Configures the Durable Functions extension for gRPC communication. + + Makes an HTTP request to the extension's management endpoint to enable gRPC. + """ + + # Make an HTTP request to the extension to configure gRPC + configure_base_url = self.httpBaseUrl + if not configure_base_url: + # For some reason, in the "bad" case when rpc has not been configured, the httpBaseUrl is empty and sent in rpcBaseUrl + configure_base_url = self.rpcBaseUrl + # configure_base_url = urlparse(configure_base_url) + # url = f"{configure_base_url.scheme}://{configure_base_url.netloc}/management/configureGrpc" + url = urljoin(configure_base_url, "management/configureGrpc") + params = { + "taskHubName": self.taskHubName, + "connectionName": self.connectionName + } + response = requests.get(url, params=params) + if response.status_code != 200: + raise Exception(f"Failed to configure gRPC for Durable Functions extension. Status code: {response.status_code}, Response: {response.text}") + + # Parse the response to update client configuration - it's double-encoded so we need to load it twice + self._parse_client_configuration(json.loads(response.text)) def create_check_status_response(self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse: """Creates an HTTP response for checking the status of a Durable Function instance. diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/constants.py b/azure-functions-durable/azure/durable_functions/constants.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/constants.py rename to azure-functions-durable/azure/durable_functions/constants.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py b/azure-functions-durable/azure/durable_functions/decorators/__init__.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/decorators/__init__.py rename to azure-functions-durable/azure/durable_functions/decorators/__init__.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py similarity index 98% rename from durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py rename to azure-functions-durable/azure/durable_functions/decorators/durable_app.py index f3f02e0a..584d3bfa 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -5,14 +5,13 @@ from durabletask import task -from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ - DurableClient from typing import Callable, Optional from typing import Union from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel -from durabletask.azurefunctions.client import DurableFunctionsClient -from durabletask.azurefunctions.worker import DurableFunctionsWorker +from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ + DurableClient +from ..worker import DurableFunctionsWorker class Blueprint(TriggerApi, BindingApi): diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py b/azure-functions-durable/azure/durable_functions/decorators/metadata.py similarity index 97% rename from durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py rename to azure-functions-durable/azure/durable_functions/decorators/metadata.py index 21cd7f42..00fed0e5 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/decorators/metadata.py +++ b/azure-functions-durable/azure/durable_functions/decorators/metadata.py @@ -3,7 +3,7 @@ from typing import Optional -from durabletask.azurefunctions.constants import ORCHESTRATION_TRIGGER, \ +from ..constants import ORCHESTRATION_TRIGGER, \ ACTIVITY_TRIGGER, ENTITY_TRIGGER, DURABLE_CLIENT from azure.functions.decorators.core import Trigger, InputBinding diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py b/azure-functions-durable/azure/durable_functions/http/__init__.py similarity index 55% rename from durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py rename to azure-functions-durable/azure/durable_functions/http/__init__.py index fc1cb6ba..b4d2c355 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/http/__init__.py +++ b/azure-functions-durable/azure/durable_functions/http/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from durabletask.azurefunctions.http.http_management_payload import HttpManagementPayload +from ..http.http_management_payload import HttpManagementPayload __all__ = ["HttpManagementPayload"] diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/http/http_management_payload.py rename to azure-functions-durable/azure/durable_functions/http/http_management_payload.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py b/azure-functions-durable/azure/durable_functions/internal/__init__.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/internal/__init__.py rename to azure-functions-durable/azure/durable_functions/internal/__init__.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_grpc_interceptor.py rename to azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/internal/azurefunctions_null_stub.py rename to azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py b/azure-functions-durable/azure/durable_functions/internal/functions_json.py similarity index 100% rename from durabletask-azurefunctions/durabletask/azurefunctions/internal/functions_json.py rename to azure-functions-durable/azure/durable_functions/internal/functions_json.py diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py b/azure-functions-durable/azure/durable_functions/worker.py similarity index 97% rename from durabletask-azurefunctions/durabletask/azurefunctions/worker.py rename to azure-functions-durable/azure/durable_functions/worker.py index 5cef7f4e..47713725 100644 --- a/durabletask-azurefunctions/durabletask/azurefunctions/worker.py +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -9,7 +9,7 @@ from durabletask.worker import _Registry, ConcurrencyOptions from durabletask.internal import shared from durabletask.worker import TaskHubGrpcWorker -from durabletask.azurefunctions.internal.azurefunctions_null_stub import AzureFunctionsNullStub +from .internal.azurefunctions_null_stub import AzureFunctionsNullStub # Worker class used for Durable Task Scheduler (DTS) diff --git a/durabletask-azurefunctions/pyproject.toml b/azure-functions-durable/pyproject.toml similarity index 86% rename from durabletask-azurefunctions/pyproject.toml rename to azure-functions-durable/pyproject.toml index 79704f0e..46faa264 100644 --- a/durabletask-azurefunctions/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -8,8 +8,8 @@ requires = ["setuptools", "wheel"] build-backend = "setuptools.build_meta" [project] -name = "durabletask.azurefunctions" -version = "0.0.1dev0" +name = "azure-functions-durable" +version = "2.0.0dev0" description = "Durable Task Python SDK provider implementation for Durable Azure Functions" keywords = [ "durable", @@ -23,7 +23,7 @@ classifiers = [ "Programming Language :: Python :: 3", "License :: OSI Approved :: MIT License", ] -requires-python = ">=3.9" +requires-python = ">=3.13" license = {file = "LICENSE"} readme = "README.md" dependencies = [ @@ -37,7 +37,7 @@ repository = "https://github.com/microsoft/durabletask-python" changelog = "https://github.com/microsoft/durabletask-python/blob/main/CHANGELOG.md" [tool.setuptools.packages.find] -include = ["durabletask.azurefunctions", "durabletask.azurefunctions.*"] +include = ["azure.durable_functions", "azure.durable_functions.*"] [tool.pytest.ini_options] minversion = "6.0" diff --git a/durabletask-azurefunctions/__init__.py b/durabletask-azurefunctions/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py b/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py deleted file mode 100644 index f34a9668..00000000 --- a/durabletask-azurefunctions/durabletask/azurefunctions/__init__.py +++ /dev/null @@ -1,11 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -# This import ensures that the replacement of the global JSON encoder/decoder -# happens as soon as the durabletask.azurefunctions package is imported. -import durabletask.azurefunctions.internal.functions_json as _ - -from durabletask.azurefunctions.decorators.durable_app import Blueprint, DFApp -from durabletask.azurefunctions.client import DurableFunctionsClient - -__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient"] diff --git a/durabletask/client.py b/durabletask/client.py index 7d037585..39fe3d0b 100644 --- a/durabletask/client.py +++ b/durabletask/client.py @@ -103,6 +103,21 @@ def __init__(self, *, interceptors: Optional[Sequence[shared.ClientInterceptor]] = None, default_version: Optional[str] = None): + self.configure_grpc_channel( + host_address=host_address, + metadata=metadata, + secure_channel=secure_channel, + interceptors=interceptors + ) + + self._logger = shared.get_logger("client", log_handler, log_formatter) + self.default_version = default_version + + def configure_grpc_channel(self, + host_address: Optional[str] = None, + metadata: Optional[list[tuple[str, str]]] = None, + secure_channel: bool = False, + interceptors: Optional[Sequence[shared.ClientInterceptor]] = None) -> None: # If the caller provided metadata, we need to create a new interceptor for it and # add it to the list of interceptors. if interceptors is not None: @@ -120,8 +135,6 @@ def __init__(self, *, interceptors=interceptors ) self._stub = stubs.TaskHubSidecarServiceStub(channel) - self._logger = shared.get_logger("client", log_handler, log_formatter) - self.default_version = default_version def schedule_new_orchestration(self, orchestrator: Union[task.Orchestrator[TInput, TOutput], str], *, input: Optional[TInput] = None, From ea5c2b06fc7695bdcb20a36f88bc70c9414b003c Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Mon, 9 Feb 2026 11:07:14 -0700 Subject: [PATCH 20/33] Re-add Orchestrator object/model --- .../azure/durable_functions/__init__.py | 3 +- .../decorators/durable_app.py | 6 +- .../azure/durable_functions/orchestrator.py | 69 +++++++++++++++++++ 3 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/orchestrator.py diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index 1c0b6f42..c15bc696 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -7,9 +7,10 @@ from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient +from .orchestrator import Orchestrator # IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable # for version detection version = "2.x" -__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient", "version"] +__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient", "Orchestrator", "version"] diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 584d3bfa..26572d40 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -12,6 +12,7 @@ from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient from ..worker import DurableFunctionsWorker +from ..orchestrator import Orchestrator class Blueprint(TriggerApi, BindingApi): @@ -58,10 +59,7 @@ def _configure_orchestrator_callable(self, wrap) -> Callable: def decorator(orchestrator_func: task.Orchestrator): # Construct an orchestrator based on the end-user code - def handle(context) -> str: - return DurableFunctionsWorker()._execute_orchestrator(orchestrator_func, context) - - handle.orchestrator_function = orchestrator_func # type: ignore + handle = Orchestrator.create(orchestrator_func) # invoke next decorator, with the Orchestrator as input handle.__name__ = orchestrator_func.__name__ diff --git a/azure-functions-durable/azure/durable_functions/orchestrator.py b/azure-functions-durable/azure/durable_functions/orchestrator.py new file mode 100644 index 00000000..6e7c6496 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/orchestrator.py @@ -0,0 +1,69 @@ +"""Durable Orchestrator. + +Responsible for orchestrating the execution of the user defined generator +function. +""" +from typing import Callable, Any, Generator + +import azure.functions as func + +from durabletask.task import OrchestrationContext + +from .worker import DurableFunctionsWorker + +class Orchestrator: + """Durable Orchestration Class. + + Responsible for orchestrating the execution of the user defined generator + function. + """ + + def __init__(self, + activity_func: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]]): + """Create a new orchestrator for the user defined generator. + + Responsible for orchestrating the execution of the user defined + generator function. + :param activity_func: Generator function to orchestrate. + """ + self.fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]] = activity_func + + def handle(self, context: OrchestrationContext) -> str: + """Handle the orchestration of the user defined generator function. + + Parameters + ---------- + context : DurableOrchestrationContext + The DF orchestration context + + Returns + ------- + str + The JSON-formatted string representing the user's orchestration + state after this invocation + """ + self.durable_context = context + return DurableFunctionsWorker()._execute_orchestrator(self.fn, context) + + @classmethod + def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]]) \ + -> Callable[[Any], str]: + """Create an instance of the orchestration class. + + Parameters + ---------- + fn: Callable[[DurableOrchestrationContext], Iterator[Any]] + Generator function that needs orchestration + + Returns + ------- + Callable[[Any], str] + Handle function of the newly created orchestration client + """ + + def handle(context) -> str: + return Orchestrator(fn).handle(context) + + handle.orchestrator_function = fn # type: ignore + + return handle From 0505a0d1712fd6789097649491f05f08be6e8c9b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 23 Jun 2026 11:01:20 -0600 Subject: [PATCH 21/33] Modernize pipelines for functions package --- .../durabletask-azurefunctions-dev.yml | 52 ---------- ...urabletask-azurefunctions-experimental.yml | 51 ---------- .../workflows/durabletask-azurefunctions.yml | 97 ++++--------------- azure-functions-durable/pyproject.toml | 3 +- durabletask/internal/shared.py | 2 +- eng/ci/release.yml | 32 ++++++ eng/templates/build.yml | 26 ++++- tests/azure-functions-durable/__init__.py | 0 tests/azure-functions-durable/test_smoke.py | 20 ++++ 9 files changed, 98 insertions(+), 185 deletions(-) delete mode 100644 .github/workflows/durabletask-azurefunctions-dev.yml delete mode 100644 .github/workflows/durabletask-azurefunctions-experimental.yml create mode 100644 tests/azure-functions-durable/__init__.py create mode 100644 tests/azure-functions-durable/test_smoke.py diff --git a/.github/workflows/durabletask-azurefunctions-dev.yml b/.github/workflows/durabletask-azurefunctions-dev.yml deleted file mode 100644 index fa7b720f..00000000 --- a/.github/workflows/durabletask-azurefunctions-dev.yml +++ /dev/null @@ -1,52 +0,0 @@ -name: Durable Task Scheduler SDK (durabletask-azurefunctions) Dev Release - -on: - workflow_run: - workflows: ["Durable Task Scheduler SDK (durabletask-azurefunctions)"] - types: - - completed - branches: - - main - -jobs: - publish-dev: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" # Adjust Python version as needed - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Append dev to version in pyproject.toml - working-directory: durabletask-azurefunctions - run: | - sed -i 's/^version = "\(.*\)"/version = "\1.dev${{ github.run_number }}"/' pyproject.toml - - - name: Build package from directory durabletask-azurefunctions - working-directory: durabletask-azurefunctions - run: | - python -m build - - - name: Check package - working-directory: durabletask-azurefunctions - run: | - twine check dist/* - - - name: Publish package to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets - working-directory: durabletask-azurefunctions - run: | - twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions-experimental.yml b/.github/workflows/durabletask-azurefunctions-experimental.yml deleted file mode 100644 index 49b8c250..00000000 --- a/.github/workflows/durabletask-azurefunctions-experimental.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Durable Task Scheduler SDK (durabletask-azurefunctions) Experimental Release - -on: - push: - branches-ignore: - - main - - release/* - -jobs: - publish-experimental: - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag - - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.14" # Adjust Python version as needed - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build twine - - - name: Change the version in pyproject.toml to 0.0.0dev{github.run_number} - working-directory: durabletask-azurefunctions - run: | - sed -i 's/^version = ".*"/version = "0.0.0.dev${{ github.run_number }}"/' pyproject.toml - sed -i 's/"durabletask>=.*"/"durabletask>=0.0.0dev1"/' pyproject.toml - - - name: Build package from directory durabletask-azurefunctions - working-directory: durabletask-azurefunctions - run: | - python -m build - - - name: Check package - working-directory: durabletask-azurefunctions - run: | - twine check dist/* - - - name: Publish package to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets - working-directory: durabletask-azurefunctions - run: | - twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/durabletask-azurefunctions.yml b/.github/workflows/durabletask-azurefunctions.yml index 2fc74540..6df274f1 100644 --- a/.github/workflows/durabletask-azurefunctions.yml +++ b/.github/workflows/durabletask-azurefunctions.yml @@ -1,4 +1,4 @@ -name: Durable Task Scheduler SDK (durabletask-azurefunctions) +name: Durable Task Scheduler SDK (azure-functions-durable) on: push: @@ -10,6 +10,9 @@ on: branches: - "main" +permissions: + contents: read + jobs: lint: runs-on: ubuntu-latest @@ -20,107 +23,45 @@ jobs: with: python-version: 3.14 - name: Install dependencies - working-directory: durabletask-azurefunctions + working-directory: azure-functions-durable run: | python -m pip install --upgrade pip pip install setuptools wheel tox pip install flake8 - name: Run flake8 Linter - working-directory: durabletask-azurefunctions + working-directory: azure-functions-durable run: flake8 . - name: Run flake8 Linter - working-directory: tests/durabletask-azurefunctions + working-directory: tests/azure-functions-durable run: flake8 . - run-docker-tests: + run-tests: strategy: fail-fast: false matrix: - python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] - env: - EMULATOR_VERSION: "latest" + python-version: ["3.13", "3.14"] needs: lint runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v4 - - name: Pull Docker image - run: docker pull mcr.microsoft.com/dts/dts-emulator:$EMULATOR_VERSION - - - name: Run Docker container - run: | - docker run --name dtsemulator -d -p 8080:8080 mcr.microsoft.com/dts/dts-emulator:$EMULATOR_VERSION - - - name: Wait for container to be ready - run: sleep 10 # Adjust if your service needs more time to start - - - name: Set environment variables - run: | - echo "TASKHUB=default" >> $GITHUB_ENV - echo "ENDPOINT=http://localhost:8080" >> $GITHUB_ENV - - - name: Install durabletask dependencies - run: | - python -m pip install --upgrade pip - pip install flake8 pytest - pip install -r requirements.txt - - - name: Install durabletask-azurefunctions dependencies - working-directory: examples - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Install durabletask-azurefunctions locally - working-directory: durabletask-azurefunctions - run: | - pip install . --no-deps --force-reinstall - - - name: Install durabletask locally - run: | - pip install . --no-deps --force-reinstall - - - name: Run the tests - working-directory: tests/durabletask-azurefunctions - run: | - pytest -m "dts" --verbose - - publish-release: - if: startsWith(github.ref, 'refs/tags/azurefunctions-v') # Only run if a matching tag is pushed - needs: run-docker-tests - runs-on: ubuntu-latest - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Extract version from tag - run: echo "VERSION=${GITHUB_REF#refs/tags/azurefunctions-v}" >> $GITHUB_ENV # Extract version from the tag - - - name: Set up Python + - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: - python-version: "3.14" # Adjust Python version as needed + python-version: ${{ matrix.python-version }} - - name: Install dependencies + - name: Install durabletask locally run: | python -m pip install --upgrade pip - pip install build twine + pip install pytest + pip install . --force-reinstall - - name: Build package from directory durabletask-azurefunctions - working-directory: durabletask-azurefunctions + - name: Install azure-functions-durable locally run: | - python -m build + pip install ./azure-functions-durable --force-reinstall - - name: Check package - working-directory: durabletask-azurefunctions + - name: Run unit tests + working-directory: tests/azure-functions-durable run: | - twine check dist/* - - - name: Publish package to PyPI - env: - TWINE_USERNAME: __token__ - TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN_AZUREFUNCTIONS }} # Store your PyPI API token in GitHub Secrets - working-directory: durabletask-azurefunctions - run: | - twine upload dist/* \ No newline at end of file + pytest -m "not dts and not azurite" --verbose diff --git a/azure-functions-durable/pyproject.toml b/azure-functions-durable/pyproject.toml index 46faa264..5f171e71 100644 --- a/azure-functions-durable/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -29,7 +29,8 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=1.25.0b3.dev1" + "azure-functions>=1.25.0b3.dev1", + "requests>=2.31.0" ] [project.urls] diff --git a/durabletask/internal/shared.py b/durabletask/internal/shared.py index 162e09a8..f8afc7c0 100644 --- a/durabletask/internal/shared.py +++ b/durabletask/internal/shared.py @@ -203,4 +203,4 @@ def dict_to_object(self, d: dict[str, Any]) -> Any: # If the object was serialized by the InternalJSONEncoder, deserialize it as a SimpleNamespace if d.pop(AUTO_SERIALIZED, False): return SimpleNamespace(**d) - return d \ No newline at end of file + return d diff --git a/eng/ci/release.yml b/eng/ci/release.yml index 7b58c7fd..335e58ee 100644 --- a/eng/ci/release.yml +++ b/eng/ci/release.yml @@ -90,3 +90,35 @@ extends: serviceendpointurl: "https://api.esrp.microsoft.com" mainpublisher: "durabletask-java" domaintenantid: "33e01921-4d64-4f8c-a055-5bdaffd5e33d" + + - job: azure_functions_durable + displayName: "Release azure-functions-durable" + templateContext: + type: releaseJob + isProduction: true + environment: durabletask-pypi-prod + inputs: + - input: pipelineArtifact + pipeline: DurableTaskPythonBuildPipeline + artifactName: drop + targetPath: $(System.DefaultWorkingDirectory)/drop + + steps: + - task: SFP.release-tasks.custom-build-release-task.EsrpRelease@9 + displayName: "ESRP Release azure-functions-durable" + inputs: + connectedservicename: "dtfx-internal-esrp-prod" + usemanagedidentity: true + keyvaultname: "durable-esrp-akv" + signcertname: "dts-esrp-cert" + clientid: "0b3ed1a4-0727-4a50-b82a-02c2bd9dec89" + intent: "PackageDistribution" + contenttype: "PyPi" + contentsource: "Folder" + folderlocation: "$(System.DefaultWorkingDirectory)/drop/buildoutputs/azure-functions-durable" + waitforreleasecompletion: true + owners: $(Build.RequestedForEmail) + approvers: $(Build.RequestedForEmail) + serviceendpointurl: "https://api.esrp.microsoft.com" + mainpublisher: "durabletask-java" + domaintenantid: "33e01921-4d64-4f8c-a055-5bdaffd5e33d" diff --git a/eng/templates/build.yml b/eng/templates/build.yml index 498ba942..c2294b04 100644 --- a/eng/templates/build.yml +++ b/eng/templates/build.yml @@ -13,9 +13,9 @@ jobs: - checkout: self - task: UsePythonVersion@0 - displayName: "Use Python 3.12" + displayName: "Use Python 3.13" inputs: - versionSpec: "3.12" + versionSpec: "3.13" addToPath: true # The 1ES pool is network-isolated, so direct pypi.org access is blocked. @@ -45,6 +45,11 @@ jobs: displayName: "flake8: durabletask-azuremanaged" workingDirectory: durabletask-azuremanaged + # Lint azurefunctions provider + - script: flake8 . + displayName: "flake8: azure-functions-durable" + workingDirectory: azure-functions-durable + # Build sdist + wheel for durabletask (core SDK) - script: | python -m build --sdist --wheel --outdir $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask . @@ -55,10 +60,16 @@ jobs: python -m build --sdist --wheel --outdir $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask-azuremanaged ./durabletask-azuremanaged displayName: "Build durabletask-azuremanaged (sdist + wheel)" + # Build sdist + wheel for azure-functions-durable + - script: | + python -m build --sdist --wheel --outdir $(Build.ArtifactStagingDirectory)/buildoutputs/azure-functions-durable ./azure-functions-durable + displayName: "Build azure-functions-durable (sdist + wheel)" + # List staged outputs for visibility in logs - script: | ls -la $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask ls -la $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask-azuremanaged + ls -la $(Build.ArtifactStagingDirectory)/buildoutputs/azure-functions-durable displayName: "List build outputs" # Install the built wheels with all declared optional extras and let @@ -89,8 +100,10 @@ jobs: # append the extras correctly. DT_WHEEL=$(ls $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask/*.whl) DT_AM_WHEEL=$(ls $(Build.ArtifactStagingDirectory)/buildoutputs/durabletask-azuremanaged/*.whl) + AF_WHEEL=$(ls $(Build.ArtifactStagingDirectory)/buildoutputs/azure-functions-durable/*.whl) python -m pip install "${DT_WHEEL}[opentelemetry,azure-blob-payloads]" python -m pip install "${DT_AM_WHEEL}[azure-blob-payloads]" + python -m pip install "${AF_WHEEL}" displayName: "Install built wheels" - script: pytest -m "not dts and not azurite" --verbose @@ -104,3 +117,12 @@ jobs: set -e python -P -c "import durabletask.azuremanaged; from durabletask.azuremanaged.client import DurableTaskSchedulerClient; from durabletask.azuremanaged.worker import DurableTaskSchedulerWorker; print('durabletask.azuremanaged smoke import OK')" displayName: "smoke import: durabletask-azuremanaged" + + # azure-functions-durable unit tests run here. Integration tests that + # require Azurite or the Azure Functions host emulator are marked + # (azurite / dts) and excluded since those external services aren't + # provisioned in this network-isolated pool. The full suite runs in + # GitHub Actions on PRs to main and main itself. + - script: pytest -m "not dts and not azurite" --verbose + displayName: "pytest: azure-functions-durable (unit tests, no emulators)" + workingDirectory: tests/azure-functions-durable diff --git a/tests/azure-functions-durable/__init__.py b/tests/azure-functions-durable/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/azure-functions-durable/test_smoke.py b/tests/azure-functions-durable/test_smoke.py new file mode 100644 index 00000000..1046663e --- /dev/null +++ b/tests/azure-functions-durable/test_smoke.py @@ -0,0 +1,20 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import azure.durable_functions as df + + +def test_public_api_is_importable(): + """Smoke test: the package imports and exposes its public API. + + This is a no-op placeholder establishing the unit-test structure for the + azure-functions-durable module. Real unit tests should be added alongside + it; integration tests that require Azurite or the Azure Functions host + emulator should be marked (e.g. ``azurite``) so they can be excluded on + the network-isolated ADO build pool. + """ + assert df.version + assert df.DFApp is not None + assert df.Blueprint is not None + assert df.DurableFunctionsClient is not None + assert df.Orchestrator is not None From c9ea2fbcf3eb83b21d9b65ae50b81433e1d526eb Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 23 Jun 2026 11:35:34 -0600 Subject: [PATCH 22/33] Cleanup pyright errors --- .github/workflows/typecheck.yml | 23 ++++ .../azure/durable_functions/__init__.py | 9 +- .../azure/durable_functions/client.py | 8 +- .../decorators/durable_app.py | 107 ++++++++++++------ .../durable_functions/decorators/metadata.py | 8 +- .../internal/azurefunctions_null_stub.py | 47 +++----- .../internal/functions_json.py | 33 +++++- .../azure/durable_functions/orchestrator.py | 11 +- .../azure/durable_functions/worker.py | 50 ++++---- azure-functions-durable/pyrightconfig.json | 16 +++ 10 files changed, 201 insertions(+), 111 deletions(-) create mode 100644 azure-functions-durable/pyrightconfig.json diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index f10463ad..cc0c2bdd 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -7,6 +7,7 @@ on: tags: - "v*" - "azuremanaged-v*" + - "azurefunctions-v*" pull_request: branches: - "main" @@ -36,3 +37,25 @@ jobs: - name: Run pyright (strict, Python 3.10) run: pyright + + pyright-azurefunctions: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python 3.13 (lowest supported by azure-functions-durable) + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install packages and dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install -e ".[azure-blob-payloads,opentelemetry]" + pip install -e ./azure-functions-durable + pip install pyright + + - name: Run pyright (strict, Python 3.13) + run: pyright -p azure-functions-durable/pyrightconfig.json diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index c15bc696..01389b80 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -1,14 +1,15 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -# This import ensures that the replacement of the global JSON encoder/decoder -# happens as soon as the durabletask.azurefunctions package is imported. -from .internal import functions_json as _ - +from .internal.functions_json import install_custom_serialization from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient from .orchestrator import Orchestrator +# Ensure the durabletask JSON encoder/decoder is replaced as soon as the +# durable_functions package is imported. +install_custom_serialization() + # IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable # for version detection version = "2.x" diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index 7ca31466..0f9583f5 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -18,7 +18,7 @@ class DurableFunctionsClient(TaskHubGrpcClient): """A gRPC client passed to Durable Functions durable client bindings. Connects to the Durable Functions runtime using gRPC and provides methods - for creating and managing Durable orchestrations, interacting with Durable entities, + for creating and managing Durable orchestrations, interacting with Durable entities, and creating HTTP management payloads and check status responses for use with Durable Functions invocations. """ taskHubName: str @@ -44,7 +44,7 @@ def __init__(self, client_as_string: str): json.JSONDecodeError: If the provided string is not valid JSON. """ self._parse_client_configuration(client_as_string) - if self.httpBaseUrl is None: + if not self.httpBaseUrl: # This happens when the extension has not been configured for gRPC yet. For some reason, instead of # the client returning with null rpcBaseUrl and httpBaseUrl, it returns rpcBaseUrl with the http url. self.configure_extension_for_grpc() @@ -87,7 +87,7 @@ def configure_extension_for_grpc(self) -> None: Makes an HTTP request to the extension's management endpoint to enable gRPC. """ - + # Make an HTTP request to the extension to configure gRPC configure_base_url = self.httpBaseUrl if not configure_base_url: @@ -103,7 +103,7 @@ def configure_extension_for_grpc(self) -> None: response = requests.get(url, params=params) if response.status_code != 200: raise Exception(f"Failed to configure gRPC for Durable Functions extension. Status code: {response.status_code}, Response: {response.text}") - + # Parse the response to update client configuration - it's double-encoded so we need to load it twice self._parse_client_configuration(json.loads(response.text)) diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 26572d40..4f828a11 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -2,12 +2,12 @@ # Licensed under the MIT License. from functools import wraps +from typing import Any, Callable, Optional, Union -from durabletask import task - -from typing import Callable, Optional -from typing import Union from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel +from azure.functions.decorators.function_app import FunctionBuilder + +from durabletask import task from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient @@ -40,9 +40,14 @@ def __init__(self, DFApp New instance of a Durable Functions app """ - super().__init__(auth_level=http_auth_level) - - def _configure_orchestrator_callable(self, wrap) -> Callable: + # The next-in-MRO base (``DecoratorApi.__init__``) is declared with + # untyped ``*args``/``**kwargs``, so pyright cannot see this call's type. + super().__init__(auth_level=http_auth_level) # pyright: ignore[reportUnknownMemberType] + + def _configure_orchestrator_callable( + self, + wrap: Callable[[Callable[..., Any]], FunctionBuilder] + ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: """Obtain decorator to construct an Orchestrator class from a user-defined Function. Parameters @@ -56,7 +61,7 @@ def _configure_orchestrator_callable(self, wrap) -> Callable: The function to construct an Orchestrator class from the user-defined Function, wrapped by the next decorator in the sequence. """ - def decorator(orchestrator_func: task.Orchestrator): + def decorator(orchestrator_func: task.Orchestrator[Any, Any]) -> FunctionBuilder: # Construct an orchestrator based on the end-user code handle = Orchestrator.create(orchestrator_func) @@ -67,7 +72,10 @@ def decorator(orchestrator_func: task.Orchestrator): return decorator - def _configure_entity_callable(self, wrap) -> Callable: + def _configure_entity_callable( + self, + wrap: Callable[[Callable[..., Any]], FunctionBuilder] + ) -> Callable[[task.Entity[Any, Any]], FunctionBuilder]: """Obtain decorator to construct an Entity class from a user-defined Function. Parameters @@ -81,16 +89,16 @@ def _configure_entity_callable(self, wrap) -> Callable: The function to construct an Entity class from the user-defined Function, wrapped by the next decorator in the sequence. """ - def decorator(entity_func: task.Entity): + def decorator(entity_func: task.Entity[Any, Any]) -> FunctionBuilder: # Construct an orchestrator based on the end-user code # TODO: Because this handle method is the one actually exposed to the Functions SDK decorator, # the parameter name will always be "context" here, even if the user specified a different name. # We need to find a way to allow custom context names (like "ctx"). - def handle(context) -> str: - return DurableFunctionsWorker()._execute_entity_batch(entity_func, context) + def handle(context: Any) -> str: + return DurableFunctionsWorker().execute_entity_batch_request(entity_func, context) - handle.entity_function = entity_func # type: ignore + handle.entity_function = entity_func # pyright: ignore[reportFunctionMemberAccess] # invoke next decorator, with the Entity as input handle.__name__ = entity_func.__name__ @@ -98,17 +106,27 @@ def handle(context) -> str: return decorator - def _add_rich_client(self, fb, parameter_name, client_constructor): + def _add_rich_client( + self, + fb: FunctionBuilder, + parameter_name: str, + client_constructor: Callable[[Any], Any] + ) -> None: # Obtain user-code and force type annotation on the client-binding parameter to be `str`. # This ensures a passing type-check of that specific parameter, # circumventing a limitation of the worker in type-checking rich DF Client objects. # TODO: Once rich-binding type checking is possible, remove the annotation change. - user_code = fb._function._func + # ``FunctionBuilder._function`` and ``Function._func`` are private to + # azure-functions with no public accessor for mutating the wrapped + # user function. Holding it as ``Any`` keeps the single private-access + # waiver here rather than spreading it across each ``._func`` use. + function_obj: Any = fb._function # pyright: ignore[reportPrivateUsage] + user_code = function_obj._func user_code.__annotations__[parameter_name] = str # `wraps` This ensures we re-export the same method-signature as the decorated method @wraps(user_code) - async def df_client_middleware(*args, **kwargs): + async def df_client_middleware(*args: Any, **kwargs: Any) -> Any: # Obtain JSON-string currently passed as DF Client, # construct rich object from it, @@ -121,13 +139,30 @@ async def df_client_middleware(*args, **kwargs): return await user_code(*args, **kwargs) # TODO: Is there a better way to support retrieving the unwrapped user code? - df_client_middleware.client_function = fb._function._func # type: ignore + df_client_middleware.client_function = function_obj._func # pyright: ignore[reportAttributeAccessIssue] - user_code_with_rich_client = df_client_middleware - fb._function._func = user_code_with_rich_client + function_obj._func = df_client_middleware + + def _build_function( + self, + wrap: Callable[[FunctionBuilder], FunctionBuilder] + ) -> Callable[[Callable[..., Any]], FunctionBuilder]: + """Typed equivalent of the base ``_configure_function_builder``. + + The inherited method is untyped, which would otherwise propagate + ``Unknown`` types through every decorator below. This mirrors its + behaviour exactly using the typed protected members it relies on. + """ + def decorator(func: Callable[..., Any]) -> FunctionBuilder: + fb = self._validate_type(func) + self._function_builders.append(fb) + return wrap(fb) + + return decorator def orchestration_trigger(self, context_name: str, - orchestration: Optional[str] = None): + orchestration: Optional[str] = None + ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: """Register an Orchestrator Function. Parameters @@ -139,10 +174,10 @@ def orchestration_trigger(self, context_name: str, The value is None by default, in which case the name of the method is used. """ @self._configure_orchestrator_callable - @self._configure_function_builder - def wrap(fb): + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: - def decorator(): + def decorator() -> FunctionBuilder: fb.add_trigger( trigger=OrchestrationTrigger(name=context_name, orchestration=orchestration)) @@ -153,7 +188,8 @@ def decorator(): return wrap def activity_trigger(self, input_name: str, - activity: Optional[str] = None): + activity: Optional[str] = None + ) -> Callable[[Callable[..., Any]], FunctionBuilder]: """Register an Activity Function. Parameters @@ -164,9 +200,9 @@ def activity_trigger(self, input_name: str, Name of Activity Function. The value is None by default, in which case the name of the method is used. """ - @self._configure_function_builder - def wrap(fb): - def decorator(): + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: fb.add_trigger( trigger=ActivityTrigger(name=input_name, activity=activity)) @@ -178,7 +214,8 @@ def decorator(): def entity_trigger(self, context_name: str, - entity_name: Optional[str] = None): + entity_name: Optional[str] = None + ) -> Callable[[task.Entity[Any, Any]], FunctionBuilder]: """Register an Entity Function. Parameters @@ -190,9 +227,9 @@ def entity_trigger(self, The value is None by default, in which case the name of the method is used. """ @self._configure_entity_callable - @self._configure_function_builder - def wrap(fb): - def decorator(): + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: fb.add_trigger( trigger=EntityTrigger(name=context_name, entity_name=entity_name)) @@ -206,7 +243,7 @@ def durable_client_input(self, client_name: str, task_hub: Optional[str] = None, connection_name: Optional[str] = None - ): + ) -> Callable[[Callable[..., Any]], FunctionBuilder]: """Register a Durable-client Function. Parameters @@ -225,9 +262,9 @@ def durable_client_input(self, account connection string for the function app is used. """ - @self._configure_function_builder - def wrap(fb): - def decorator(): + @self._build_function + def wrap(fb: FunctionBuilder) -> FunctionBuilder: + def decorator() -> FunctionBuilder: fb.add_binding( binding=DurableClient(name=client_name, task_hub=task_hub, diff --git a/azure-functions-durable/azure/durable_functions/decorators/metadata.py b/azure-functions-durable/azure/durable_functions/decorators/metadata.py index 00fed0e5..efe3983d 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/metadata.py +++ b/azure-functions-durable/azure/durable_functions/decorators/metadata.py @@ -28,7 +28,7 @@ def get_binding_name() -> str: def __init__(self, name: str, orchestration: Optional[str] = None, - durable_requires_grpc=True, + durable_requires_grpc: bool = True, ) -> None: self.orchestration = orchestration self.durable_requires_grpc = durable_requires_grpc @@ -55,7 +55,7 @@ def get_binding_name() -> str: def __init__(self, name: str, activity: Optional[str] = None, - durable_requires_grpc=True, + durable_requires_grpc: bool = True, ) -> None: self.activity = activity self.durable_requires_grpc = durable_requires_grpc @@ -82,7 +82,7 @@ def get_binding_name() -> str: def __init__(self, name: str, entity_name: Optional[str] = None, - durable_requires_grpc=True, + durable_requires_grpc: bool = True, ) -> None: self.entity_name = entity_name self.durable_requires_grpc = durable_requires_grpc @@ -110,7 +110,7 @@ def __init__(self, name: str, task_hub: Optional[str] = None, connection_name: Optional[str] = None, - durable_requires_grpc=True, + durable_requires_grpc: bool = True, ) -> None: self.task_hub = task_hub self.connection_name = connection_name diff --git a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py index 75a48a0a..af8593d1 100644 --- a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py +++ b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_null_stub.py @@ -1,34 +1,23 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from durabletask.internal.proto_task_hub_sidecar_service_stub import ProtoTaskHubSidecarServiceStub +from typing import Any, Callable -class AzureFunctionsNullStub(ProtoTaskHubSidecarServiceStub): - """A task hub sidecar stub class that implements all methods as no-ops.""" - Hello = lambda *args, **kwargs: None - StartInstance = lambda *args, **kwargs: None - GetInstance = lambda *args, **kwargs: None - RewindInstance = lambda *args, **kwargs: None - WaitForInstanceStart = lambda *args, **kwargs: None - WaitForInstanceCompletion = lambda *args, **kwargs: None - RaiseEvent = lambda *args, **kwargs: None - TerminateInstance = lambda *args, **kwargs: None - SuspendInstance = lambda *args, **kwargs: None - ResumeInstance = lambda *args, **kwargs: None - QueryInstances = lambda *args, **kwargs: None - PurgeInstances = lambda *args, **kwargs: None - GetWorkItems = lambda *args, **kwargs: None - CompleteActivityTask = lambda *args, **kwargs: None - CompleteOrchestratorTask = lambda *args, **kwargs: None - CompleteEntityTask = lambda *args, **kwargs: None - StreamInstanceHistory = lambda *args, **kwargs: None - CreateTaskHub = lambda *args, **kwargs: None - DeleteTaskHub = lambda *args, **kwargs: None - SignalEntity = lambda *args, **kwargs: None - GetEntity = lambda *args, **kwargs: None - QueryEntities = lambda *args, **kwargs: None - CleanEntityStorage = lambda *args, **kwargs: None - AbandonTaskActivityWorkItem = lambda *args, **kwargs: None - AbandonTaskOrchestratorWorkItem = lambda *args, **kwargs: None - AbandonTaskEntityWorkItem = lambda *args, **kwargs: None +class AzureFunctionsNullStub: + """A task hub sidecar stub whose every method is a no-op. + + Instances structurally satisfy the methods of + ``ProtoTaskHubSidecarServiceStub`` without inheriting from that + ``Protocol`` (a ``Protocol`` subclass cannot be instantiated). Any + attribute access resolves to a callable that ignores its arguments and + returns ``None``, which is sufficient because the Azure Functions worker + replaces the relevant completion callbacks before invoking the base + worker logic. + """ + + def __getattr__(self, name: str) -> Callable[..., None]: + def _noop(*args: Any, **kwargs: Any) -> None: + return None + + return _noop diff --git a/azure-functions-durable/azure/durable_functions/internal/functions_json.py b/azure-functions-durable/azure/durable_functions/internal/functions_json.py index 71d2b721..383e255d 100644 --- a/azure-functions-durable/azure/durable_functions/internal/functions_json.py +++ b/azure-functions-durable/azure/durable_functions/internal/functions_json.py @@ -1,10 +1,37 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import importlib import json -from azure.functions._durable_functions import _serialize_custom_object, _deserialize_custom_object +from typing import Any, Callable + from durabletask.internal import shared +# ``azure.functions`` only exposes its custom-object (de)serialization helpers +# from a private module, and they are untyped. Resolve them dynamically and +# bind them to locally-typed callables so the rest of the module stays fully +# type-checked. +_df_serializers = importlib.import_module("azure.functions._durable_functions") +_serialize_custom_object: Callable[[Any], Any] = getattr( + _df_serializers, "_serialize_custom_object") +_deserialize_custom_object: Callable[[dict[str, Any]], Any] = getattr( + _df_serializers, "_deserialize_custom_object") + + +def _to_json(obj: Any) -> str: + return json.dumps(obj, default=_serialize_custom_object) + + +def _from_json(json_str: str | bytes | bytearray) -> Any: + return json.loads(json_str, object_hook=_deserialize_custom_object) + + +def install_custom_serialization() -> None: + """Replace durabletask's global JSON (de)serialization helpers. -shared.to_json = lambda obj: json.dumps(obj, default=_serialize_custom_object) -shared.from_json = lambda json_str: json.loads(json_str, object_hook=_deserialize_custom_object) \ No newline at end of file + Routes ``durabletask`` payload serialization through azure-functions' + custom-object (de)serializers so that user types round-trip consistently + between the Functions host and the durabletask runtime. + """ + shared.to_json = _to_json + shared.from_json = _from_json diff --git a/azure-functions-durable/azure/durable_functions/orchestrator.py b/azure-functions-durable/azure/durable_functions/orchestrator.py index 6e7c6496..168ee61e 100644 --- a/azure-functions-durable/azure/durable_functions/orchestrator.py +++ b/azure-functions-durable/azure/durable_functions/orchestrator.py @@ -3,14 +3,13 @@ Responsible for orchestrating the execution of the user defined generator function. """ -from typing import Callable, Any, Generator - -import azure.functions as func +from typing import Any, Callable, Generator from durabletask.task import OrchestrationContext from .worker import DurableFunctionsWorker + class Orchestrator: """Durable Orchestration Class. @@ -43,7 +42,7 @@ def handle(self, context: OrchestrationContext) -> str: state after this invocation """ self.durable_context = context - return DurableFunctionsWorker()._execute_orchestrator(self.fn, context) + return DurableFunctionsWorker().execute_orchestration_request(self.fn, context) @classmethod def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]]) \ @@ -61,9 +60,9 @@ def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, An Handle function of the newly created orchestration client """ - def handle(context) -> str: + def handle(context: Any) -> str: return Orchestrator(fn).handle(context) - handle.orchestrator_function = fn # type: ignore + handle.orchestrator_function = fn # pyright: ignore[reportFunctionMemberAccess] return handle diff --git a/azure-functions-durable/azure/durable_functions/worker.py b/azure-functions-durable/azure/durable_functions/worker.py index 47713725..b17f0c16 100644 --- a/azure-functions-durable/azure/durable_functions/worker.py +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -2,12 +2,16 @@ # Licensed under the MIT License. import base64 -from threading import Event -from typing import Optional +from typing import Any, Optional + from durabletask import task -from durabletask.internal.orchestrator_service_pb2 import EntityBatchRequest, EntityBatchResult, OrchestratorRequest, OrchestratorResponse -from durabletask.worker import _Registry, ConcurrencyOptions -from durabletask.internal import shared +from durabletask.internal.orchestrator_service_pb2 import ( + EntityBatchRequest, + EntityBatchResult, + HistoryEvent, + OrchestratorRequest, + OrchestratorResponse, +) from durabletask.worker import TaskHubGrpcWorker from .internal.azurefunctions_null_stub import AzureFunctionsNullStub @@ -20,38 +24,32 @@ class DurableFunctionsWorker(TaskHubGrpcWorker): See TaskHubGrpcWorker for base class documentation. """ - def __init__(self): - # Don't call the parent constructor - we don't actually want to start an AsyncWorkerLoop - # or recieve work items from anywhere but the method that is creating this worker - self._registry = _Registry() - self._host_address = "" - self._logger = shared.get_logger("worker") - self._shutdown = Event() - self._is_running = False - self._secure_channel = False - - self._concurrency_options = ConcurrencyOptions() - - self._interceptors = None + def __init__(self) -> None: + # We never start the worker loop or open a gRPC channel. The base + # constructor only initialises in-memory state (registry, logger, + # concurrency options, payload store, etc.) that the inherited + # ``_execute_*`` methods rely on; work items are delivered directly by + # the methods below rather than streamed from a sidecar. + super().__init__() - def add_named_orchestrator(self, name: str, func: task.Orchestrator): + def add_named_orchestrator(self, name: str, func: task.Orchestrator[Any, Any]) -> None: self._registry.add_named_orchestrator(name, func) - def _execute_orchestrator(self, func: task.Orchestrator, context) -> str: + def execute_orchestration_request(self, func: task.Orchestrator[Any, Any], context: Any) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context orchestration_context = context_body request = OrchestratorRequest() request.ParseFromString(base64.b64decode(orchestration_context)) - stub = AzureFunctionsNullStub() + stub: Any = AzureFunctionsNullStub() response: Optional[OrchestratorResponse] = None - def stub_complete(stub_response): + def stub_complete(stub_response: OrchestratorResponse) -> None: nonlocal response response = stub_response stub.CompleteOrchestratorTask = stub_complete - execution_started_events = [] + execution_started_events: list[HistoryEvent] = [] for e in request.pastEvents: if e.HasField("executionStarted"): execution_started_events.append(e) @@ -70,17 +68,17 @@ def stub_complete(stub_response): # The Python worker returns the input as type "json", so double-encoding is necessary return base64.b64encode(response.SerializeToString()).decode('utf-8') - def _execute_entity_batch(self, func: task.Entity, context) -> str: + def execute_entity_batch_request(self, func: task.Entity[Any, Any], context: Any) -> str: context_body = getattr(context, "body", None) if context_body is None: context_body = context orchestration_context = context_body request = EntityBatchRequest() request.ParseFromString(base64.b64decode(orchestration_context)) - stub = AzureFunctionsNullStub() + stub: Any = AzureFunctionsNullStub() response: Optional[EntityBatchResult] = None - def stub_complete(stub_response: EntityBatchResult): + def stub_complete(stub_response: EntityBatchResult) -> None: nonlocal response response = stub_response stub.CompleteEntityTask = stub_complete diff --git a/azure-functions-durable/pyrightconfig.json b/azure-functions-durable/pyrightconfig.json new file mode 100644 index 00000000..fc3affe5 --- /dev/null +++ b/azure-functions-durable/pyrightconfig.json @@ -0,0 +1,16 @@ +{ + "include": [ + "azure" + ], + "extraPaths": [ + ".." + ], + "exclude": [ + "**/__pycache__", + "**/.venv*", + ".venv*", + "build" + ], + "pythonVersion": "3.13", + "typeCheckingMode": "strict" +} From 5b906bd90f58436229bf041b5c35476d1c4f746b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 23 Jun 2026 11:56:10 -0600 Subject: [PATCH 23/33] Remove non-existent extension call --- .../azure/durable_functions/client.py | 32 +------------------ azure-functions-durable/pyproject.toml | 3 +- 2 files changed, 2 insertions(+), 33 deletions(-) diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index 0f9583f5..b3f7e549 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -5,12 +5,11 @@ from datetime import timedelta import azure.functions as func -from urllib.parse import urlparse, urljoin, quote +from urllib.parse import urlparse, quote from durabletask.client import TaskHubGrpcClient from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl from .http import HttpManagementPayload -import requests # Client class used for Durable Functions @@ -44,10 +43,6 @@ def __init__(self, client_as_string: str): json.JSONDecodeError: If the provided string is not valid JSON. """ self._parse_client_configuration(client_as_string) - if not self.httpBaseUrl: - # This happens when the extension has not been configured for gRPC yet. For some reason, instead of - # the client returning with null rpcBaseUrl and httpBaseUrl, it returns rpcBaseUrl with the http url. - self.configure_extension_for_grpc() interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] @@ -82,31 +77,6 @@ def _parse_client_configuration(self, client_as_string: str) -> None: # TODO: convert the string value back to timedelta - annoying regex? self.grpcHttpClientTimeout = client.get("grpcHttpClientTimeout", timedelta(seconds=30)) - def configure_extension_for_grpc(self) -> None: - """Configures the Durable Functions extension for gRPC communication. - - Makes an HTTP request to the extension's management endpoint to enable gRPC. - """ - - # Make an HTTP request to the extension to configure gRPC - configure_base_url = self.httpBaseUrl - if not configure_base_url: - # For some reason, in the "bad" case when rpc has not been configured, the httpBaseUrl is empty and sent in rpcBaseUrl - configure_base_url = self.rpcBaseUrl - # configure_base_url = urlparse(configure_base_url) - # url = f"{configure_base_url.scheme}://{configure_base_url.netloc}/management/configureGrpc" - url = urljoin(configure_base_url, "management/configureGrpc") - params = { - "taskHubName": self.taskHubName, - "connectionName": self.connectionName - } - response = requests.get(url, params=params) - if response.status_code != 200: - raise Exception(f"Failed to configure gRPC for Durable Functions extension. Status code: {response.status_code}, Response: {response.text}") - - # Parse the response to update client configuration - it's double-encoded so we need to load it twice - self._parse_client_configuration(json.loads(response.text)) - def create_check_status_response(self, request: func.HttpRequest, instance_id: str) -> func.HttpResponse: """Creates an HTTP response for checking the status of a Durable Function instance. diff --git a/azure-functions-durable/pyproject.toml b/azure-functions-durable/pyproject.toml index 5f171e71..46faa264 100644 --- a/azure-functions-durable/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -29,8 +29,7 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=1.25.0b3.dev1", - "requests>=2.31.0" + "azure-functions>=1.25.0b3.dev1" ] [project.urls] From df9932db4d1b741d65cf70a8c69176569024847b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 1 Jul 2026 11:18:19 -0600 Subject: [PATCH 24/33] Serialization compat, fix typing, working again --- .../azure/durable_functions/__init__.py | 5 - .../azure/durable_functions/client.py | 4 +- .../decorators/durable_app.py | 8 +- .../internal/functions_json.py | 37 ------ .../internal/serialization.py | 118 ++++++++++++++++++ .../azure/durable_functions/orchestrator.py | 18 ++- .../azure/durable_functions/worker.py | 7 +- azure-functions-durable/pyproject.toml | 2 +- 8 files changed, 149 insertions(+), 50 deletions(-) delete mode 100644 azure-functions-durable/azure/durable_functions/internal/functions_json.py create mode 100644 azure-functions-durable/azure/durable_functions/internal/serialization.py diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index 01389b80..470afe84 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -1,15 +1,10 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from .internal.functions_json import install_custom_serialization from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient from .orchestrator import Orchestrator -# Ensure the durabletask JSON encoder/decoder is replaced as soon as the -# durable_functions package is imported. -install_custom_serialization() - # IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable # for version detection version = "2.x" diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index b3f7e549..23aa0c42 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -9,6 +9,7 @@ from durabletask.client import TaskHubGrpcClient from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl +from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload @@ -52,7 +53,8 @@ def __init__(self, client_as_string: str): host_address=self.rpcBaseUrl, secure_channel=False, metadata=None, - interceptors=interceptors) + interceptors=interceptors, + data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER) def _parse_client_configuration(self, client_as_string: str) -> None: """Parses the client configuration JSON string and sets instance variables. diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 4f828a11..8c2d68df 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -4,6 +4,7 @@ from functools import wraps from typing import Any, Callable, Optional, Union +import azure.functions as func from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel from azure.functions.decorators.function_app import FunctionBuilder @@ -95,7 +96,12 @@ def decorator(entity_func: task.Entity[Any, Any]) -> FunctionBuilder: # TODO: Because this handle method is the one actually exposed to the Functions SDK decorator, # the parameter name will always be "context" here, even if the user specified a different name. # We need to find a way to allow custom context names (like "ctx"). - def handle(context: Any) -> str: + # The generated handle is what the Azure Functions host registers, + # so its ``context`` parameter must be annotated with + # ``azure.functions.EntityContext`` for the host's entityTrigger + # binding converter to accept it; at runtime the host passes that + # transport context (exposing ``.body``). + def handle(context: func.EntityContext) -> str: return DurableFunctionsWorker().execute_entity_batch_request(entity_func, context) handle.entity_function = entity_func # pyright: ignore[reportFunctionMemberAccess] diff --git a/azure-functions-durable/azure/durable_functions/internal/functions_json.py b/azure-functions-durable/azure/durable_functions/internal/functions_json.py deleted file mode 100644 index 383e255d..00000000 --- a/azure-functions-durable/azure/durable_functions/internal/functions_json.py +++ /dev/null @@ -1,37 +0,0 @@ -# Copyright (c) Microsoft Corporation. -# Licensed under the MIT License. - -import importlib -import json -from typing import Any, Callable - -from durabletask.internal import shared - -# ``azure.functions`` only exposes its custom-object (de)serialization helpers -# from a private module, and they are untyped. Resolve them dynamically and -# bind them to locally-typed callables so the rest of the module stays fully -# type-checked. -_df_serializers = importlib.import_module("azure.functions._durable_functions") -_serialize_custom_object: Callable[[Any], Any] = getattr( - _df_serializers, "_serialize_custom_object") -_deserialize_custom_object: Callable[[dict[str, Any]], Any] = getattr( - _df_serializers, "_deserialize_custom_object") - - -def _to_json(obj: Any) -> str: - return json.dumps(obj, default=_serialize_custom_object) - - -def _from_json(json_str: str | bytes | bytearray) -> Any: - return json.loads(json_str, object_hook=_deserialize_custom_object) - - -def install_custom_serialization() -> None: - """Replace durabletask's global JSON (de)serialization helpers. - - Routes ``durabletask`` payload serialization through azure-functions' - custom-object (de)serializers so that user types round-trip consistently - between the Functions host and the durabletask runtime. - """ - shared.to_json = _to_json - shared.from_json = _from_json diff --git a/azure-functions-durable/azure/durable_functions/internal/serialization.py b/azure-functions-durable/azure/durable_functions/internal/serialization.py new file mode 100644 index 00000000..803b26fd --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/serialization.py @@ -0,0 +1,118 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Azure Functions payload serialization for Durable Task. + +Bridges durabletask's pluggable :class:`~durabletask.serialization.DataConverter` +to the azure-functions SDK's centralized ``df_dumps`` / ``df_loads`` serializers +so that payloads round-trip through the **exact** wire format the Durable +Functions host extension (and the SDK's ``ActivityTriggerConverter``) expect: +builtins as plain JSON, custom objects wrapped in the +``{"__class__", "__module__", "__data__"}`` envelope via their ``to_json`` / +``from_json`` hooks. + +When the installed ``azure-functions`` package exposes ``df_dumps`` / ``df_loads`` +(centralized serializers with optional type validation and strict-typing +support), they are used directly. On older releases that lack them we fall back +to the legacy ``_serialize_custom_object`` / ``_deserialize_custom_object`` hooks +-- the same behavior the SDK converter uses in those versions -- keeping both +sides symmetric. The wire format is unchanged either way. +""" + +from __future__ import annotations + +import importlib +import json +import logging +from typing import Any, Callable, Optional, cast + +from durabletask.serialization import JsonDataConverter + +logger = logging.getLogger("azure.functions.DurableFunctions") + +# ``azure.functions`` only exposes its Durable serialization helpers from a +# private, untyped module. Resolve it dynamically and bind the symbols we need +# to locally-typed callables so the rest of the module stays type-checked. +_df_internal = importlib.import_module("azure.functions._durable_functions") +_serialize_custom_object: Callable[[Any], Any] = getattr( + _df_internal, "_serialize_custom_object") +_deserialize_custom_object: Callable[[dict[str, Any]], Any] = getattr( + _df_internal, "_deserialize_custom_object") + +_FALLBACK_MESSAGE = ( + "The installed 'azure-functions' package does not provide the centralized " + "'df_dumps' / 'df_loads' serializers. Durable Functions is falling back to " + "the legacy serialization pipeline; the wire format is unchanged, but " + "payload type validation (the 'expected_type' argument and strict typing " + "mode) is unavailable. Upgrade to azure-functions>=1.26.0b4 to enable " + "type-validated serialization." +) + +_warned = False + + +def _warn_fallback_once() -> None: + # Deferred to first use (debug level) rather than emitted at import time, so + # users who never exercise the fallback path are not spammed. + global _warned + if not _warned: + _warned = True + logger.debug(_FALLBACK_MESSAGE) + + +def _fallback_df_dumps(value: Any) -> str: + """Serialize ``value`` via the legacy custom-object hook.""" + _warn_fallback_once() + return json.dumps(value, default=_serialize_custom_object) + + +def _fallback_df_loads(s: str, expected_type: Optional[type] = None) -> Any: + """Deserialize ``s`` via the legacy custom-object hook. + + ``expected_type`` is accepted for call-site compatibility but ignored on + this fallback path; type validation is only performed by the SDK's + ``df_loads`` when it is available. + """ + _warn_fallback_once() + return json.loads(s, object_hook=_deserialize_custom_object) + + +# Prefer the SDK's centralized serializers; fall back to the legacy hooks when +# they are unavailable (older azure-functions releases). +_sdk_df_dumps = getattr(_df_internal, "df_dumps", None) +_sdk_df_loads = getattr(_df_internal, "df_loads", None) + +df_dumps: Callable[[Any], str] = ( + cast("Callable[[Any], str]", _sdk_df_dumps) + if callable(_sdk_df_dumps) else _fallback_df_dumps) +df_loads: Callable[..., Any] = ( + cast("Callable[..., Any]", _sdk_df_loads) + if callable(_sdk_df_loads) else _fallback_df_loads) + + +class FunctionsDataConverter(JsonDataConverter): + """:class:`DataConverter` that serializes via azure-functions' codec. + + Overrides only the string boundary (:meth:`serialize` / :meth:`deserialize`) + to route through ``df_dumps`` / ``df_loads`` -- producing the + ``{"__class__", "__module__", "__data__"}`` envelope that the Durable + Functions host expects -- while inheriting :class:`JsonDataConverter`'s + value-level :meth:`coerce` and reconstruction policy + (:meth:`can_reconstruct`), which operate on already-parsed values and are + wire-format agnostic. + """ + + def serialize(self, value: Any) -> str | None: + if value is None: + return None + return df_dumps(value) + + def deserialize(self, data: str | None, target_type: type | None = None) -> Any: + if data is None or data == "": + return None + return df_loads(data, target_type) + + +# Shared instance: the converter is stateless, so a single instance is reused +# across the per-invocation worker/client objects. +DEFAULT_FUNCTIONS_DATA_CONVERTER: FunctionsDataConverter = FunctionsDataConverter() diff --git a/azure-functions-durable/azure/durable_functions/orchestrator.py b/azure-functions-durable/azure/durable_functions/orchestrator.py index 168ee61e..e70d033f 100644 --- a/azure-functions-durable/azure/durable_functions/orchestrator.py +++ b/azure-functions-durable/azure/durable_functions/orchestrator.py @@ -5,6 +5,8 @@ """ from typing import Any, Callable, Generator +import azure.functions as func + from durabletask.task import OrchestrationContext from .worker import DurableFunctionsWorker @@ -27,13 +29,16 @@ def __init__(self, """ self.fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]] = activity_func - def handle(self, context: OrchestrationContext) -> str: + def handle(self, context: func.OrchestrationContext) -> str: """Handle the orchestration of the user defined generator function. Parameters ---------- - context : DurableOrchestrationContext - The DF orchestration context + context : azure.functions.OrchestrationContext + The Durable Functions orchestration trigger context. This is the + transport wrapper supplied by the host (it exposes ``.body``); the + user's orchestrator function receives a durabletask + ``OrchestrationContext`` during execution inside the worker. Returns ------- @@ -60,7 +65,12 @@ def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, An Handle function of the newly created orchestration client """ - def handle(context: Any) -> str: + # The generated handle is the function registered with the Azure + # Functions host. Its ``context`` parameter must be annotated with + # ``azure.functions.OrchestrationContext`` so the host's + # orchestrationTrigger binding converter accepts it; at runtime the + # host passes that transport context (exposing ``.body``). + def handle(context: func.OrchestrationContext) -> str: return Orchestrator(fn).handle(context) handle.orchestrator_function = fn # pyright: ignore[reportFunctionMemberAccess] diff --git a/azure-functions-durable/azure/durable_functions/worker.py b/azure-functions-durable/azure/durable_functions/worker.py index b17f0c16..1e972141 100644 --- a/azure-functions-durable/azure/durable_functions/worker.py +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -14,6 +14,7 @@ ) from durabletask.worker import TaskHubGrpcWorker from .internal.azurefunctions_null_stub import AzureFunctionsNullStub +from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER # Worker class used for Durable Task Scheduler (DTS) @@ -30,7 +31,11 @@ def __init__(self) -> None: # concurrency options, payload store, etc.) that the inherited # ``_execute_*`` methods rely on; work items are delivered directly by # the methods below rather than streamed from a sidecar. - super().__init__() + # + # The Functions converter routes payload serialization through the + # azure-functions codec (df_dumps/df_loads) so user types round-trip in + # the wire format the Durable Functions host extension expects. + super().__init__(data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER) def add_named_orchestrator(self, name: str, func: task.Orchestrator[Any, Any]) -> None: self._registry.add_named_orchestrator(name, func) diff --git a/azure-functions-durable/pyproject.toml b/azure-functions-durable/pyproject.toml index 46faa264..1f6941d9 100644 --- a/azure-functions-durable/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -29,7 +29,7 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=1.25.0b3.dev1" + "azure-functions>=2.2.0b6" ] [project.urls] From a7bdd0134df41d3112b5e1b1a525c9ce780f9485 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 10:23:17 -0600 Subject: [PATCH 25/33] Use the async client (match old behavior) --- azure-functions-durable/CHANGELOG.md | 10 +++++ .../azure/durable_functions/client.py | 10 ++--- .../azurefunctions_grpc_interceptor.py | 43 ++++++++++++++----- 3 files changed, 47 insertions(+), 16 deletions(-) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index b9be1590..2eab7ecb 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Changed + +- `DurableFunctionsClient` is now an async client. Its orchestration and entity + management methods (e.g. `schedule_new_orchestration`, `get_orchestration_state`, + `wait_for_orchestration_completion`) are now coroutines and must be awaited. + This aligns the client with the async API surface of the V1 + `DurableOrchestrationClient`. + ## v0.1.0 - Initial implementation diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index 23aa0c42..5404e1e0 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -7,17 +7,17 @@ import azure.functions as func from urllib.parse import urlparse, quote -from durabletask.client import TaskHubGrpcClient -from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl +from durabletask.client import AsyncTaskHubGrpcClient +from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload # Client class used for Durable Functions -class DurableFunctionsClient(TaskHubGrpcClient): +class DurableFunctionsClient(AsyncTaskHubGrpcClient): """A gRPC client passed to Durable Functions durable client bindings. - Connects to the Durable Functions runtime using gRPC and provides methods + Connects to the Durable Functions runtime using async gRPC and provides methods for creating and managing Durable orchestrations, interacting with Durable entities, and creating HTTP management payloads and check status responses for use with Durable Functions invocations. """ @@ -45,7 +45,7 @@ def __init__(self, client_as_string: str): """ self._parse_client_configuration(client_as_string) - interceptors = [AzureFunctionsDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] + interceptors = [AzureFunctionsAsyncDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] # We pass in None for the metadata so we don't construct an additional interceptor in the parent class # Since the parent class doesn't use anything metadata for anything else, we can set it as None diff --git a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py index 8736bf6f..f4011322 100644 --- a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py +++ b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py @@ -3,7 +3,24 @@ from importlib.metadata import version -from durabletask.internal.grpc_interceptor import DefaultClientInterceptorImpl +from durabletask.internal.grpc_interceptor import ( + DefaultAsyncClientInterceptorImpl, + DefaultClientInterceptorImpl, +) + + +def _build_metadata(taskhub_name: str) -> list[tuple[str, str]]: + """Build the gRPC metadata headers sent on every Durable Functions call.""" + try: + # Get the version of the azurefunctions package + sdk_version = version('durabletask-azurefunctions') + except Exception: + # Fallback if version cannot be determined + sdk_version = "unknown" + user_agent = f"durabletask-python/{sdk_version}" + return [ + ("taskhub", taskhub_name), + ("x-user-agent", user_agent)] # 'user-agent' is a reserved header in grpc, so we use 'x-user-agent' instead class AzureFunctionsDefaultClientInterceptorImpl(DefaultClientInterceptorImpl): @@ -14,14 +31,18 @@ class AzureFunctionsDefaultClientInterceptorImpl(DefaultClientInterceptorImpl): def __init__(self, taskhub_name: str, required_query_string_parameters: str): self.required_query_string_parameters = required_query_string_parameters - try: - # Get the version of the azurefunctions package - sdk_version = version('durabletask-azurefunctions') - except Exception: - # Fallback if version cannot be determined - sdk_version = "unknown" - user_agent = f"durabletask-python/{sdk_version}" - self._metadata = [ - ("taskhub", taskhub_name), - ("x-user-agent", user_agent)] # 'user-agent' is a reserved header in grpc, so we use 'x-user-agent' instead + self._metadata = _build_metadata(taskhub_name) + super().__init__(self._metadata) + + +class AzureFunctionsAsyncDefaultClientInterceptorImpl(DefaultAsyncClientInterceptorImpl): + """Async version of AzureFunctionsDefaultClientInterceptorImpl for use with grpc.aio channels. + + This class implements async gRPC interceptors to add Durable Functions headers + (task hub name and user agent) to all async calls.""" + required_query_string_parameters: str + + def __init__(self, taskhub_name: str, required_query_string_parameters: str): + self.required_query_string_parameters = required_query_string_parameters + self._metadata = _build_metadata(taskhub_name) super().__init__(self._metadata) From 1891d94b5936cc118741d26caf54779bd412a839 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 11:39:46 -0600 Subject: [PATCH 26/33] Compat layer V1 --- azure-functions-durable/CHANGELOG.md | 45 ++ .../azure/durable_functions/__init__.py | 39 +- .../azure/durable_functions/client.py | 310 +++++++++- .../azure/durable_functions/compat_aliases.py | 71 +++ .../durable_orchestration_status.py | 115 ++++ .../azure/durable_functions/entity_id.py | 40 ++ .../entity_state_response.py | 41 ++ .../http/http_management_payload.py | 29 + .../orchestration_runtime_status.py | 101 +++ .../durable_functions/purge_history_result.py | 27 + .../azure/durable_functions/retry_options.py | 46 ++ .../azure/durable_functions/token_source.py | 51 ++ azure-functions-durable/pyproject.toml | 3 +- .../test_client_compat.py | 573 ++++++++++++++++++ 14 files changed, 1478 insertions(+), 13 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/compat_aliases.py create mode 100644 azure-functions-durable/azure/durable_functions/durable_orchestration_status.py create mode 100644 azure-functions-durable/azure/durable_functions/entity_id.py create mode 100644 azure-functions-durable/azure/durable_functions/entity_state_response.py create mode 100644 azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py create mode 100644 azure-functions-durable/azure/durable_functions/purge_history_result.py create mode 100644 azure-functions-durable/azure/durable_functions/retry_options.py create mode 100644 azure-functions-durable/azure/durable_functions/token_source.py create mode 100644 tests/azure-functions-durable/test_client_compat.py diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 2eab7ecb..6dc0ece5 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -7,6 +7,44 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Added + +- Backwards-compatible, deprecated aliases on `DurableFunctionsClient` for the + v1 `DurableOrchestrationClient` method names: `start_new`, `get_status`, + `get_status_all`, `get_status_by`, `raise_event`, `terminate`, + `purge_instance_history`, `purge_instance_history_by`, `suspend`, `resume`, + `restart`, `read_entity_state`, `get_client_response_links`, and + `wait_for_completion_or_create_check_status_response`. Each delegates to the + corresponding durabletask method and emits a `DeprecationWarning`; new code + should use the durabletask names (e.g. `schedule_new_orchestration`, + `get_orchestration_state`). +- `DurableFunctionsClient.signal_entity` now also accepts the v1 + `operation_input` keyword (alias for `input`); `task_hub_name` and + `connection_name` are accepted for compatibility and ignored. +- `DurableFunctionsClient.rewind` is present as a deprecated stub that raises + `NotImplementedError`, pending a durabletask rewind implementation. +- Deprecated v1 compatibility aliases are now exported from + `azure.durable_functions`: `DurableOrchestrationClient` (alias for + `DurableFunctionsClient`), `DurableOrchestrationContext`, `DurableEntityContext`, + `EntityId`, `ManagedIdentityTokenSource`, `TokenSource`, `Entity`, and + `OrchestrationRuntimeStatus`. +- v1-compatible return-type wrappers `DurableOrchestrationStatus`, + `PurgeHistoryResult`, and `EntityStateResponse` (exported from + `azure.durable_functions`). The deprecated client methods now return these: + `get_status`/`get_status_all`/`get_status_by` return + `DurableOrchestrationStatus` (wrapping durabletask `OrchestrationState`, with + v1 attributes like `runtime_status`, `output`, `input_`, `custom_status`, and + a falsy value for missing instances); `purge_instance_history`/`_by` return + `PurgeHistoryResult` (with `instances_deleted`); and `read_entity_state` + returns `EntityStateResponse` (with `entity_exists`/`entity_state`). +- `DurableOrchestrationContext.call_http` is present as a stub that raises + `NotImplementedError`, documenting the durable-HTTP gap. `TokenSource` / + `ManagedIdentityTokenSource` remain constructible but only apply to + `call_http`, which is not yet supported. +- `RetryOptions`, a deprecated shim that maps the v1 millisecond-based + constructor onto durabletask `RetryPolicy` (which uses `timedelta`). + `RetryPolicy` is now also exported from `azure.durable_functions`. + ### Changed - `DurableFunctionsClient` is now an async client. Its orchestration and entity @@ -14,6 +52,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `wait_for_orchestration_completion`) are now coroutines and must be awaited. This aligns the client with the async API surface of the V1 `DurableOrchestrationClient`. +- `create_http_management_payload` now accepts either the durabletask + `(request, instance_id)` signature or the v1 `(instance_id)` signature for + backwards compatibility. +- `HttpManagementPayload` now supports mapping-style access + (`payload["statusQueryGetUri"]`, iteration, `in`, `keys()`/`items()`/`values()`) + so v1 code that treated the payload as a `dict` keeps working. + ## v0.1.0 diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index 470afe84..e415b137 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -1,12 +1,49 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +# This module intentionally re-exports deprecated v1 compatibility aliases. +# pyright: reportDeprecated=false + +from durabletask.task import RetryPolicy + from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient from .orchestrator import Orchestrator +from .retry_options import RetryOptions +from .orchestration_runtime_status import OrchestrationRuntimeStatus +from .durable_orchestration_status import DurableOrchestrationStatus +from .purge_history_result import PurgeHistoryResult +from .entity_state_response import EntityStateResponse +from .entity_id import EntityId +from .token_source import ManagedIdentityTokenSource, TokenSource +from .compat_aliases import ( + DurableEntityContext, + DurableOrchestrationClient, + DurableOrchestrationContext, + Entity, +) # IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable # for version detection version = "2.x" -__all__ = ["Blueprint", "DFApp", "DurableFunctionsClient", "Orchestrator", "version"] +__all__ = [ + "Blueprint", + "DFApp", + "DurableEntityContext", + "DurableFunctionsClient", + "DurableOrchestrationClient", + "DurableOrchestrationContext", + "DurableOrchestrationStatus", + "Entity", + "EntityId", + "EntityStateResponse", + "ManagedIdentityTokenSource", + "Orchestrator", + "OrchestrationRuntimeStatus", + "PurgeHistoryResult", + "RetryOptions", + "RetryPolicy", + "TokenSource", + "version", +] diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index 5404e1e0..c98048ae 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -3,14 +3,25 @@ import json -from datetime import timedelta +from datetime import datetime, timedelta +from typing import Any, Optional, Union +from typing_extensions import deprecated import azure.functions as func from urllib.parse import urlparse, quote -from durabletask.client import AsyncTaskHubGrpcClient +from durabletask.client import ( + AsyncTaskHubGrpcClient, + OrchestrationQuery, + OrchestrationStatus, +) +from durabletask.entities import EntityInstanceId from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload +from .durable_orchestration_status import DurableOrchestrationStatus +from .entity_state_response import EntityStateResponse +from .orchestration_runtime_status import OrchestrationRuntimeStatus, to_durabletask_statuses +from .purge_history_result import PurgeHistoryResult # Client class used for Durable Functions @@ -96,22 +107,299 @@ def create_check_status_response(self, request: func.HttpRequest, instance_id: s }, ) - def create_http_management_payload(self, request: func.HttpRequest, instance_id: str) -> HttpManagementPayload: + def create_http_management_payload( + self, + request: func.HttpRequest | str | None = None, + instance_id: str | None = None) -> HttpManagementPayload: """Creates an HTTP management payload for a Durable Function instance. + Two call styles are supported: + + - ``create_http_management_payload(request, instance_id)`` (recommended): + builds the payload URLs relative to the incoming request's origin. + - ``create_http_management_payload(instance_id)`` (deprecated V1 style): + builds the payload URLs from the client binding's base URL when no + request is available. + Args: - instance_id (str): The ID of the Durable Function instance. + request (func.HttpRequest | str | None): The incoming HTTP request, or, + for backwards compatibility, the instance ID when called with a + single positional argument. + instance_id (str | None): The ID of the Durable Function instance. """ - return self._get_client_response_links(request, instance_id) + # Backwards-compatibility: v1 accepted a single positional ``instance_id``. + if instance_id is None and isinstance(request, str): + instance_id = request + request = None + if instance_id is None: + raise TypeError("instance_id is required") + resolved_request = request if isinstance(request, func.HttpRequest) else None + return self._get_client_response_links(resolved_request, instance_id) - def _get_client_response_links(self, request: func.HttpRequest, instance_id: str) -> HttpManagementPayload: + def _get_client_response_links(self, request: func.HttpRequest | None, instance_id: str) -> HttpManagementPayload: instance_status_url = self._get_instance_status_url(request, instance_id) return HttpManagementPayload(instance_id, instance_status_url, self.requiredQueryStringParameters) - @staticmethod - def _get_instance_status_url(request: func.HttpRequest, instance_id: str) -> str: - request_url = urlparse(request.url) - location_url = f"{request_url.scheme}://{request_url.netloc}" + def _get_instance_status_url(self, request: func.HttpRequest | None, instance_id: str) -> str: encoded_instance_id = quote(instance_id) - location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_instance_id + if request is not None: + request_url = urlparse(request.url) + location_url = f"{request_url.scheme}://{request_url.netloc}" + location_url = location_url + "/runtime/webhooks/durabletask/instances/" + encoded_instance_id + else: + # No request available (v1-style call): fall back to the base URL + # supplied in the client binding configuration. + base_url = self.baseUrl.rstrip("/") if self.baseUrl else "" + location_url = base_url + "/instances/" + encoded_instance_id return location_url + + # ------------------------------------------------------------------ + # Backwards-compatibility shims for the v1 azure-functions-durable + # DurableOrchestrationClient API. These delegate to the durabletask + # AsyncTaskHubGrpcClient methods and are deprecated: new code should use + # the durabletask method names directly. + # ------------------------------------------------------------------ + + @deprecated("start_new is deprecated; use schedule_new_orchestration instead.") + async def start_new(self, + orchestration_function_name: str, + instance_id: Optional[str] = None, + client_input: Optional[Any] = None, + version: Optional[str] = None) -> str: + """Deprecated alias for :meth:`schedule_new_orchestration`.""" + return await self.schedule_new_orchestration( + orchestration_function_name, + input=client_input, + instance_id=instance_id, + version=version) + + @deprecated("get_status is deprecated; use get_orchestration_state instead.") + async def get_status( + self, + instance_id: str, + show_history: bool = False, + show_history_output: bool = False, + show_input: bool = False) -> DurableOrchestrationStatus: + """Deprecated alias for :meth:`get_orchestration_state`. + + Returns a :class:`DurableOrchestrationStatus` wrapping the durabletask + ``OrchestrationState`` for v1 back-compat. When the instance does not + exist, a falsy status is returned rather than ``None``. + + The ``show_history`` and ``show_history_output`` flags have no + equivalent in durabletask and are ignored; ``show_input`` maps to + ``fetch_payloads``. + """ + state = await self.get_orchestration_state(instance_id, fetch_payloads=show_input) + return DurableOrchestrationStatus.from_orchestration_state(state) + + @deprecated("get_status_all is deprecated; use get_all_orchestration_states instead.") + async def get_status_all(self) -> list[DurableOrchestrationStatus]: + """Deprecated alias for :meth:`get_all_orchestration_states`.""" + states = await self.get_all_orchestration_states() + return [DurableOrchestrationStatus.from_orchestration_state(state) for state in states] + + @deprecated("raise_event is deprecated; use raise_orchestration_event instead.") + async def raise_event( + self, + instance_id: str, + event_name: str, + event_data: Any = None, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> None: + """Deprecated alias for :meth:`raise_orchestration_event`. + + The ``task_hub_name`` and ``connection_name`` arguments have no + equivalent in durabletask and are ignored. + """ + await self.raise_orchestration_event(instance_id, event_name, data=event_data) + + @deprecated("terminate is deprecated; use terminate_orchestration instead.") + async def terminate(self, instance_id: str, reason: Optional[Any] = None) -> None: + """Deprecated alias for :meth:`terminate_orchestration`. + + The v1 ``reason`` maps to the durabletask ``output`` argument. + """ + await self.terminate_orchestration(instance_id, output=reason) + + @deprecated("purge_instance_history is deprecated; use purge_orchestration instead.") + async def purge_instance_history(self, instance_id: str) -> PurgeHistoryResult: + """Deprecated alias for :meth:`purge_orchestration`. + + Returns a :class:`PurgeHistoryResult` wrapping the durabletask + ``PurgeInstancesResult`` for v1 back-compat. + """ + result = await self.purge_orchestration(instance_id) + return PurgeHistoryResult.from_purge_result(result) + + @deprecated("suspend is deprecated; use suspend_orchestration instead.") + async def suspend(self, instance_id: str, reason: Optional[str] = None) -> None: + """Deprecated alias for :meth:`suspend_orchestration`. + + The v1 ``reason`` argument has no equivalent in durabletask and is + ignored. + """ + await self.suspend_orchestration(instance_id) + + @deprecated("resume is deprecated; use resume_orchestration instead.") + async def resume(self, instance_id: str, reason: Optional[str] = None) -> None: + """Deprecated alias for :meth:`resume_orchestration`. + + The v1 ``reason`` argument has no equivalent in durabletask and is + ignored. + """ + await self.resume_orchestration(instance_id) + + @deprecated("restart is deprecated; use restart_orchestration instead.") + async def restart( + self, + instance_id: str, + restart_with_new_instance_id: bool = True) -> str: + """Deprecated alias for :meth:`restart_orchestration`.""" + return await self.restart_orchestration( + instance_id, restart_with_new_instance_id=restart_with_new_instance_id) + + @deprecated("read_entity_state is deprecated; use get_entity instead.") + async def read_entity_state( + self, + entity_instance_id: EntityInstanceId, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> EntityStateResponse: + """Deprecated alias for :meth:`get_entity`. + + Returns an :class:`EntityStateResponse` wrapping the durabletask + ``EntityMetadata`` for v1 back-compat. + + The ``task_hub_name`` and ``connection_name`` arguments have no + equivalent in durabletask and are ignored. + """ + metadata = await self.get_entity(entity_instance_id) + return EntityStateResponse.from_entity_metadata(metadata) + + @deprecated("get_status_by is deprecated; use get_all_orchestration_states instead.") + async def get_status_by( + self, + created_time_from: Optional[datetime] = None, + created_time_to: Optional[datetime] = None, + runtime_status: Optional[list[OrchestrationRuntimeStatus]] = None) -> list[DurableOrchestrationStatus]: + """Deprecated alias for :meth:`get_all_orchestration_states`. + + The v1 ``OrchestrationRuntimeStatus`` values are mapped onto the + durabletask ``OrchestrationStatus`` enum, and results are wrapped in + :class:`DurableOrchestrationStatus` for v1 back-compat. + """ + query = OrchestrationQuery( + created_time_from=created_time_from, + created_time_to=created_time_to, + runtime_status=to_durabletask_statuses(runtime_status)) + states = await self.get_all_orchestration_states(query) + return [DurableOrchestrationStatus.from_orchestration_state(state) for state in states] + + @deprecated("purge_instance_history_by is deprecated; use purge_orchestrations_by instead.") + async def purge_instance_history_by( + self, + created_time_from: Optional[datetime] = None, + created_time_to: Optional[datetime] = None, + runtime_status: Optional[list[OrchestrationRuntimeStatus]] = None) -> PurgeHistoryResult: + """Deprecated alias for :meth:`purge_orchestrations_by`. + + The v1 ``OrchestrationRuntimeStatus`` values are mapped onto the + durabletask ``OrchestrationStatus`` enum, and the result is wrapped in + :class:`PurgeHistoryResult` for v1 back-compat. + """ + result = await self.purge_orchestrations_by( + created_time_from=created_time_from, + created_time_to=created_time_to, + runtime_status=to_durabletask_statuses(runtime_status)) + return PurgeHistoryResult.from_purge_result(result) + + async def signal_entity( + self, + entity_instance_id: EntityInstanceId, + operation_name: str, + input: Any = None, + signal_time: Optional[datetime] = None, + *, + operation_input: Any = None, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> None: + """Signal an entity to perform an operation. + + Accepts the durabletask ``input`` argument as well as the v1 + ``operation_input`` alias. The ``task_hub_name`` and ``connection_name`` + arguments have no equivalent in durabletask and are ignored. + """ + resolved_input = operation_input if operation_input is not None else input + await super().signal_entity( + entity_instance_id, operation_name, input=resolved_input, signal_time=signal_time) + + @deprecated( + "get_client_response_links is deprecated; use create_http_management_payload instead.") + def get_client_response_links( + self, + request: Optional[func.HttpRequest], + instance_id: str) -> HttpManagementPayload: + """Deprecated alias for :meth:`create_http_management_payload`.""" + return self._get_client_response_links(request, instance_id) + + @deprecated( + "wait_for_completion_or_create_check_status_response is deprecated; use " + "wait_for_orchestration_completion together with create_check_status_response instead.") + async def wait_for_completion_or_create_check_status_response( + self, + request: func.HttpRequest, + instance_id: str, + timeout_in_milliseconds: int = 10000, + retry_interval_in_milliseconds: int = 1000) -> func.HttpResponse: + """Wait for an orchestration to complete, or return a check-status response. + + If the orchestration completes within the timeout, an HTTP response + containing its output (or failure) is returned; otherwise a + check-status response is returned. + + The ``retry_interval_in_milliseconds`` argument has no durabletask + equivalent (durabletask waits server-side) and is ignored. + """ + if retry_interval_in_milliseconds > timeout_in_milliseconds: + raise Exception( + f'Total timeout {timeout_in_milliseconds} (ms) should be bigger than ' + f'retry timeout {retry_interval_in_milliseconds} (ms)') + + try: + state = await self.wait_for_orchestration_completion( + instance_id, timeout=timeout_in_milliseconds / 1000) + except TimeoutError: + return self.create_check_status_response(request, instance_id) + + if state is None: + return self.create_check_status_response(request, instance_id) + + if state.runtime_status == OrchestrationStatus.COMPLETED: + return self._create_http_response(200, state.serialized_output) + if state.runtime_status == OrchestrationStatus.TERMINATED: + return self._create_http_response(200, state.serialized_output) + if state.runtime_status == OrchestrationStatus.FAILED: + return self._create_http_response(500, state.serialized_output) + return self.create_check_status_response(request, instance_id) + + @deprecated( + "rewind is not yet supported in durabletask; this shim raises " + "NotImplementedError.") + async def rewind( + self, + instance_id: str, + reason: str, + task_hub_name: Optional[str] = None, + connection_name: Optional[str] = None) -> None: + """Not implemented: durabletask has no rewind equivalent yet.""" + raise NotImplementedError( + "rewind is not yet supported by durabletask.") + + @staticmethod + def _create_http_response(status_code: int, body: Union[str, Any]) -> func.HttpResponse: + body_as_json = body if isinstance(body, str) else json.dumps(body) + return func.HttpResponse( + status_code=status_code, + body=body_as_json, + mimetype="application/json", + headers={"Content-Type": "application/json"}) diff --git a/azure-functions-durable/azure/durable_functions/compat_aliases.py b/azure-functions-durable/azure/durable_functions/compat_aliases.py new file mode 100644 index 00000000..1d53a17b --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/compat_aliases.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any, Optional + +from typing_extensions import deprecated + +from durabletask.entities import EntityContext +from durabletask.task import OrchestrationContext + +from .client import DurableFunctionsClient +from .token_source import TokenSource + + +@deprecated( + "DurableOrchestrationClient is deprecated; use DurableFunctionsClient instead.") +class DurableOrchestrationClient(DurableFunctionsClient): + """Deprecated alias for :class:`DurableFunctionsClient`.""" + + +@deprecated( + "DurableOrchestrationContext is deprecated; use " + "durabletask.task.OrchestrationContext instead.") +class DurableOrchestrationContext(OrchestrationContext): + """Deprecated alias for :class:`durabletask.task.OrchestrationContext`. + + Retained so v1 type hints (``def my_orchestrator(context: DurableOrchestrationContext)``) + continue to import. At runtime the durabletask worker passes an + ``OrchestrationContext`` instance. + """ + + def call_http(self, + method: str, + uri: str, + content: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + token_source: Optional[TokenSource] = None, + is_raw_str: bool = False) -> Any: + """Schedule a durable HTTP call (v1 API). + + Not yet supported: durabletask has no durable-HTTP (``call_http``) + equivalent, so this raises ``NotImplementedError``. It is present to + document the v1 API surface and the current gap. + """ + raise NotImplementedError( + "call_http is not yet supported by durabletask. The durable-HTTP " + "API (and its TokenSource auth) has no durabletask equivalent yet.") + + +@deprecated( + "DurableEntityContext is deprecated; use " + "durabletask.entities.EntityContext instead.") +class DurableEntityContext(EntityContext): + """Deprecated alias for :class:`durabletask.entities.EntityContext`.""" + + +@deprecated( + "The Entity class is deprecated and unsupported in v2; register entities " + "with the entity_trigger decorator instead.") +class Entity: + """Deprecated placeholder for the v1 ``Entity`` executor class. + + Entities in v2 are registered with the ``entity_trigger`` decorator and + executed by the durabletask worker; there is no user-facing ``Entity`` + class. This placeholder is retained only so existing imports do not fail. + """ + + def __init__(self, *args: Any, **kwargs: Any): + raise NotImplementedError( + "The Entity class is not supported in v2. Register entities with " + "the entity_trigger decorator instead.") diff --git a/azure-functions-durable/azure/durable_functions/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/durable_orchestration_status.py new file mode 100644 index 00000000..2db4f94f --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/durable_orchestration_status.py @@ -0,0 +1,115 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from datetime import datetime +from typing import Any, Optional + +from durabletask.client import OrchestrationState + +from .orchestration_runtime_status import ( + OrchestrationRuntimeStatus, + from_durabletask_status, +) + + +class DurableOrchestrationStatus: + """Represents the status of a durable orchestration instance. + + Backwards-compatible wrapper around the durabletask + :class:`~durabletask.client.OrchestrationState`. It exposes the v1 + ``DurableOrchestrationStatus`` attribute surface so existing code that reads + ``status.runtime_status``, ``status.output``, ``status.input_``, etc. keeps + working. New code should use ``OrchestrationState`` directly. + + A status wrapping ``None`` (i.e. a non-existent instance) is falsy, matching + the v1 behaviour where ``get_status`` never returned ``None``. + """ + + def __init__(self, state: Optional[OrchestrationState] = None): + self._state = state + + @classmethod + def from_orchestration_state( + cls, state: Optional[OrchestrationState]) -> "DurableOrchestrationStatus": + """Wrap a durabletask ``OrchestrationState`` (or ``None``).""" + return cls(state) + + def __bool__(self) -> bool: + return self._state is not None + + @property + def orchestration_state(self) -> Optional[OrchestrationState]: + """Get the underlying durabletask ``OrchestrationState`` (or ``None``).""" + return self._state + + @property + def name(self) -> Optional[str]: + """Get the orchestrator function name.""" + return self._state.name if self._state is not None else None + + @property + def instance_id(self) -> Optional[str]: + """Get the unique ID of the instance.""" + return self._state.instance_id if self._state is not None else None + + @property + def created_time(self) -> Optional[datetime]: + """Get the time at which the orchestration instance was created.""" + return self._state.created_at if self._state is not None else None + + @property + def last_updated_time(self) -> Optional[datetime]: + """Get the time at which the orchestration instance last updated.""" + return self._state.last_updated_at if self._state is not None else None + + @property + def input_(self) -> Any: + """Get the (deserialized) input of the orchestration instance.""" + return self._state.get_input() if self._state is not None else None + + @property + def output(self) -> Any: + """Get the (deserialized) output of the orchestration instance.""" + return self._state.get_output() if self._state is not None else None + + @property + def runtime_status(self) -> Optional[OrchestrationRuntimeStatus]: + """Get the runtime status as a v1 ``OrchestrationRuntimeStatus``.""" + if self._state is None: + return None + return from_durabletask_status(self._state.runtime_status) + + @property + def custom_status(self) -> Any: + """Get the (deserialized) custom status payload, if any.""" + return self._state.get_custom_status() if self._state is not None else None + + @property + def history(self) -> Optional[list[Any]]: + """Get the execution history. + + History is not available through this compatibility path and is always + ``None``; use ``get_orchestration_history`` on the client instead. + """ + return None + + def to_json(self) -> dict[str, Any]: + """Convert this status into a v1-compatible JSON dictionary.""" + result: dict[str, Any] = {} + if self.name is not None: + result["name"] = self.name + if self.instance_id is not None: + result["instanceId"] = self.instance_id + if self.created_time is not None: + result["createdTime"] = self.created_time.isoformat() + if self.last_updated_time is not None: + result["lastUpdatedTime"] = self.last_updated_time.isoformat() + if self.output is not None: + result["output"] = self.output + if self.input_ is not None: + result["input"] = self.input_ + if self.runtime_status is not None: + result["runtimeStatus"] = self.runtime_status.name + if self.custom_status is not None: + result["customStatus"] = self.custom_status + return result diff --git a/azure-functions-durable/azure/durable_functions/entity_id.py b/azure-functions-durable/azure/durable_functions/entity_id.py new file mode 100644 index 00000000..7f296ae1 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/entity_id.py @@ -0,0 +1,40 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing_extensions import deprecated + +from durabletask.entities import EntityInstanceId + + +@deprecated( + "EntityId is deprecated; use durabletask.entities.EntityInstanceId instead.") +class EntityId(EntityInstanceId): + """Backwards-compatible shim for the v1 ``EntityId`` class. + + Identifies an entity by its name and key. New code should use + :class:`durabletask.entities.EntityInstanceId`. + """ + + def __init__(self, name: str, key: str): + """Instantiate an EntityId object. + + Args: + name (str): The entity name. + key (str): The entity key. + """ + super().__init__(entity=name, key=key) + + @property + def name(self) -> str: + """Get the entity name (v1 alias for ``entity``).""" + return self.entity + + @staticmethod + def get_scheduler_id(entity_id: EntityInstanceId) -> str: + """Produce a scheduler ID string (``@name@key``) from an entity ID.""" + return str(entity_id) + + @staticmethod + def get_entity_id(scheduler_id: str) -> EntityInstanceId: + """Return an entity ID from a scheduler ID string (``@name@key``).""" + return EntityInstanceId.parse(scheduler_id) diff --git a/azure-functions-durable/azure/durable_functions/entity_state_response.py b/azure-functions-durable/azure/durable_functions/entity_state_response.py new file mode 100644 index 00000000..dd593d39 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/entity_state_response.py @@ -0,0 +1,41 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any, Optional + +from durabletask.entities.entity_metadata import EntityMetadata + + +class EntityStateResponse: + """Entity state response object for ``read_entity_state``. + + Backwards-compatible wrapper around the durabletask + :class:`~durabletask.entities.entity_metadata.EntityMetadata`. New code + should use ``get_entity`` and the returned ``EntityMetadata`` directly. + """ + + def __init__(self, entity_exists: bool, entity_state: Any = None): + self._entity_exists = entity_exists + self._entity_state = entity_state + + @classmethod + def from_entity_metadata( + cls, metadata: Optional[EntityMetadata]) -> "EntityStateResponse": + """Build a response from a durabletask ``EntityMetadata`` (or ``None``).""" + if metadata is None: + return cls(False) + state = metadata.get_typed_state() if metadata.includes_state else None + return cls(True, state) + + @property + def entity_exists(self) -> bool: + """Get the bool representing whether the entity exists.""" + return self._entity_exists + + @property + def entity_state(self) -> Any: + """Get the state of the entity. + + When ``entity_exists`` is ``False``, this value is ``None``. + """ + return self._entity_state diff --git a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py index a6836844..4c1b634b 100644 --- a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py +++ b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py @@ -2,6 +2,7 @@ # Licensed under the MIT License. import json +from collections.abc import Iterator class HttpManagementPayload: @@ -9,6 +10,10 @@ class HttpManagementPayload: Contains URLs for managing the instance, such as querying status, sending events, terminating, restarting, etc. + + Supports mapping-style access (``payload["statusQueryGetUri"]``, iteration, + ``in``, ``.keys()``/``.items()``/``.values()``) for backwards compatibility + with the v1 API, which returned a plain ``dict``. """ def __init__(self, instance_id: str, instance_status_url: str, required_query_string_parameters: str): @@ -32,3 +37,27 @@ def __init__(self, instance_id: str, instance_status_url: str, required_query_st def __str__(self): return json.dumps(self.urls) + + def __getitem__(self, key: str) -> str: + return self.urls[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.urls) + + def __len__(self) -> int: + return len(self.urls) + + def __contains__(self, key: object) -> bool: + return key in self.urls + + def keys(self): + """Return the management URL keys.""" + return self.urls.keys() + + def items(self): + """Return the management URL (key, value) pairs.""" + return self.urls.items() + + def values(self): + """Return the management URL values.""" + return self.urls.values() diff --git a/azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py b/azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py new file mode 100644 index 00000000..71b595d4 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py @@ -0,0 +1,101 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from enum import Enum + +from durabletask.client import OrchestrationStatus + + +class OrchestrationRuntimeStatus(Enum): + """The status of an orchestration instance. + + Backwards-compatible enum matching the v1 ``OrchestrationRuntimeStatus`` + values. New code should use :class:`durabletask.client.OrchestrationStatus`. + """ + + Running = 'Running' + """The orchestration instance has started running.""" + + Completed = 'Completed' + """The orchestration instance has completed normally.""" + + ContinuedAsNew = 'ContinuedAsNew' + """The orchestration instance has restarted itself with a new history. + + This is a transient state. + """ + + Failed = 'Failed' + """The orchestration instance failed with an error.""" + + Canceled = 'Canceled' + """The orchestration was canceled gracefully.""" + + Terminated = 'Terminated' + """The orchestration instance was stopped abruptly.""" + + Pending = 'Pending' + """The orchestration instance has been scheduled but has not yet started running.""" + + Suspended = 'Suspended' + """The orchestration instance has been suspended and may go back to running at a later time.""" + + +# Maps the v1 OrchestrationRuntimeStatus members onto the durabletask +# OrchestrationStatus enum. ``Canceled`` has no durabletask equivalent. +_TO_DURABLETASK_STATUS: dict[OrchestrationRuntimeStatus, OrchestrationStatus] = { + OrchestrationRuntimeStatus.Running: OrchestrationStatus.RUNNING, + OrchestrationRuntimeStatus.Completed: OrchestrationStatus.COMPLETED, + OrchestrationRuntimeStatus.ContinuedAsNew: OrchestrationStatus.CONTINUED_AS_NEW, + OrchestrationRuntimeStatus.Failed: OrchestrationStatus.FAILED, + OrchestrationRuntimeStatus.Terminated: OrchestrationStatus.TERMINATED, + OrchestrationRuntimeStatus.Pending: OrchestrationStatus.PENDING, + OrchestrationRuntimeStatus.Suspended: OrchestrationStatus.SUSPENDED, +} + + +def to_durabletask_status(status: "OrchestrationRuntimeStatus") -> OrchestrationStatus: + """Convert a v1 ``OrchestrationRuntimeStatus`` to a durabletask ``OrchestrationStatus``. + + Raises + ------ + ValueError + If the status has no durabletask equivalent (e.g. ``Canceled``). + """ + try: + return _TO_DURABLETASK_STATUS[status] + except KeyError: + raise ValueError( + f"OrchestrationRuntimeStatus.{status.name} has no durabletask " + "OrchestrationStatus equivalent.") + + +def to_durabletask_statuses( + statuses: "list[OrchestrationRuntimeStatus] | None") -> "list[OrchestrationStatus] | None": + """Convert a list of v1 statuses to durabletask statuses, preserving ``None``.""" + if statuses is None: + return None + return [to_durabletask_status(status) for status in statuses] + + +# Reverse mapping: durabletask OrchestrationStatus -> v1 OrchestrationRuntimeStatus. +# Every durabletask status has a v1 equivalent (``Canceled`` is v1-only). +_FROM_DURABLETASK_STATUS: dict[OrchestrationStatus, OrchestrationRuntimeStatus] = { + durabletask_status: v1_status + for v1_status, durabletask_status in _TO_DURABLETASK_STATUS.items() +} + + +def from_durabletask_status(status: OrchestrationStatus) -> "OrchestrationRuntimeStatus": + """Convert a durabletask ``OrchestrationStatus`` to a v1 ``OrchestrationRuntimeStatus``. + + Raises + ------ + ValueError + If the status has no v1 equivalent. + """ + try: + return _FROM_DURABLETASK_STATUS[status] + except KeyError: + raise ValueError( + f"OrchestrationStatus {status} has no v1 OrchestrationRuntimeStatus equivalent.") diff --git a/azure-functions-durable/azure/durable_functions/purge_history_result.py b/azure-functions-durable/azure/durable_functions/purge_history_result.py new file mode 100644 index 00000000..e9d300ff --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/purge_history_result.py @@ -0,0 +1,27 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from durabletask.client import PurgeInstancesResult + + +class PurgeHistoryResult: + """Information provided when a request to purge history has been made. + + Backwards-compatible wrapper around the durabletask + :class:`~durabletask.client.PurgeInstancesResult`. New code should use + ``PurgeInstancesResult`` directly (note the attribute is + ``deleted_instance_count`` there). + """ + + def __init__(self, instances_deleted: int): + self._instances_deleted = instances_deleted + + @classmethod + def from_purge_result(cls, result: PurgeInstancesResult) -> "PurgeHistoryResult": + """Wrap a durabletask ``PurgeInstancesResult``.""" + return cls(result.deleted_instance_count) + + @property + def instances_deleted(self) -> int: + """Get the number of deleted instances.""" + return self._instances_deleted diff --git a/azure-functions-durable/azure/durable_functions/retry_options.py b/azure-functions-durable/azure/durable_functions/retry_options.py new file mode 100644 index 00000000..1943007b --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/retry_options.py @@ -0,0 +1,46 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from datetime import timedelta + +from typing_extensions import deprecated + +from durabletask.task import RetryPolicy + + +@deprecated( + "RetryOptions is deprecated; use durabletask.task.RetryPolicy with " + "timedelta values instead.") +class RetryOptions(RetryPolicy): + """Backwards-compatible shim for the v1 ``RetryOptions`` class. + + This maps the v1 millisecond-based constructor onto the durabletask + :class:`~durabletask.task.RetryPolicy`, which uses ``timedelta`` values. + New code should use ``RetryPolicy`` directly. + """ + + def __init__( + self, + first_retry_interval_in_milliseconds: int, + max_number_of_attempts: int): + """Create a new RetryOptions instance. + + Args: + first_retry_interval_in_milliseconds (int): The retry interval, in + milliseconds, to use for the first retry attempt. Must be + greater than 0. + max_number_of_attempts (int): The maximum number of retry attempts. + """ + if first_retry_interval_in_milliseconds <= 0: + raise ValueError( + "first_retry_interval_in_milliseconds value must be greater than 0.") + + super().__init__( + first_retry_interval=timedelta( + milliseconds=first_retry_interval_in_milliseconds), + max_number_of_attempts=max_number_of_attempts) + + @property + def first_retry_interval_in_milliseconds(self) -> int: + """Get the first retry interval, in milliseconds.""" + return int(self.first_retry_interval / timedelta(milliseconds=1)) diff --git a/azure-functions-durable/azure/durable_functions/token_source.py b/azure-functions-durable/azure/durable_functions/token_source.py new file mode 100644 index 00000000..5b990f85 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/token_source.py @@ -0,0 +1,51 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from abc import ABC + +from typing_extensions import deprecated + + +class TokenSource(ABC): + """Token source abstract base class. + + Backwards-compatible shim for the v1 ``TokenSource``. Token sources are + consumed only by the orchestrator ``call_http`` API, which has no + durabletask equivalent yet — see + :meth:`DurableOrchestrationContext.call_http`. Constructing a token source + is harmless, but it cannot be used until ``call_http`` is supported. + """ + + def __init__(self): + super().__init__() + + +@deprecated( + "ManagedIdentityTokenSource is deprecated; it is only usable with the " + "orchestrator call_http API, which is not yet available in durabletask.") +class ManagedIdentityTokenSource(TokenSource): + """Returns a ``ManagedIdentityTokenSource`` object. + + Only meaningful when passed to ``call_http`` (not yet supported in + durabletask). Constructing one is allowed for import/config compatibility. + """ + + def __init__(self, resource: str): + """Create a ManagedIdentityTokenSource. + + Args: + resource (str): The Azure Active Directory resource identifier of the + web API being invoked. + """ + super().__init__() + self._resource: str = resource + self._kind: str = "AzureManagedIdentity" + + @property + def resource(self) -> str: + """Get the Azure Active Directory resource identifier of the web API being invoked.""" + return self._resource + + def to_json(self) -> dict[str, str]: + """Convert this object into a JSON-serializable dictionary.""" + return {"resource": self._resource, "kind": self._kind} diff --git a/azure-functions-durable/pyproject.toml b/azure-functions-durable/pyproject.toml index 1f6941d9..3c6fc6f6 100644 --- a/azure-functions-durable/pyproject.toml +++ b/azure-functions-durable/pyproject.toml @@ -29,7 +29,8 @@ readme = "README.md" dependencies = [ "durabletask>=1.2.0dev0", "azure-identity>=1.19.0", - "azure-functions>=2.2.0b6" + "azure-functions>=2.2.0b6", + "typing-extensions>=4.9.0" ] [project.urls] diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py new file mode 100644 index 00000000..fa18d2e1 --- /dev/null +++ b/tests/azure-functions-durable/test_client_compat.py @@ -0,0 +1,573 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json +from datetime import datetime, timedelta, timezone +from types import SimpleNamespace +from unittest.mock import AsyncMock, patch + +import azure.functions as func +import pytest + +import azure.durable_functions as df +from azure.durable_functions import RetryOptions +from azure.durable_functions.orchestration_runtime_status import ( + from_durabletask_status, + to_durabletask_status, + to_durabletask_statuses, +) +from durabletask.client import AsyncTaskHubGrpcClient, OrchestrationStatus +from durabletask.entities import EntityInstanceId +from durabletask.task import RetryPolicy + + +_CLIENT_CONFIG = json.dumps({ + "taskHubName": "TestHub", + "requiredQueryStringParameters": "code=xyz", + "baseUrl": "http://localhost:7071/runtime/webhooks/durabletask", + "rpcBaseUrl": "http://localhost:8080/", + "managementUrls": {"id": "INSTANCEID"}, +}) + + +def _make_client() -> df.DurableFunctionsClient: + return df.DurableFunctionsClient(_CLIENT_CONFIG) + + +# --------------------------------------------------------------------------- +# RetryOptions shim +# --------------------------------------------------------------------------- + +def test_retry_options_is_retry_policy_subclass(): + assert issubclass(RetryOptions, RetryPolicy) + + +def test_retry_options_maps_milliseconds_to_timedelta(): + with pytest.warns(DeprecationWarning): + options = RetryOptions( + first_retry_interval_in_milliseconds=1500, + max_number_of_attempts=3) + assert options.first_retry_interval == timedelta(milliseconds=1500) + assert options.max_number_of_attempts == 3 + assert options.first_retry_interval_in_milliseconds == 1500 + + +def test_retry_options_rejects_non_positive_interval(): + with pytest.warns(DeprecationWarning): + with pytest.raises(ValueError): + RetryOptions( + first_retry_interval_in_milliseconds=0, + max_number_of_attempts=3) + + +def test_retry_policy_is_exported(): + assert df.RetryPolicy is RetryPolicy + + +# --------------------------------------------------------------------------- +# create_http_management_payload signature compatibility +# --------------------------------------------------------------------------- + +async def test_create_http_management_payload_v1_signature(): + client = _make_client() + try: + payload = client.create_http_management_payload("inst1") + assert payload.urls["id"] == "inst1" + assert payload.urls["statusQueryGetUri"] == ( + "http://localhost:7071/runtime/webhooks/durabletask/instances/inst1?code=xyz") + finally: + await client.close() + + +async def test_create_http_management_payload_v2_signature(): + client = _make_client() + try: + request = func.HttpRequest( + method="POST", url="http://localhost:7071/api/start", body=b"") + payload = client.create_http_management_payload(request, "inst2") + assert payload.urls["id"] == "inst2" + assert payload.urls["statusQueryGetUri"] == ( + "http://localhost:7071/runtime/webhooks/durabletask/instances/inst2?code=xyz") + finally: + await client.close() + + +async def test_create_http_management_payload_requires_instance_id(): + client = _make_client() + try: + with pytest.raises(TypeError): + client.create_http_management_payload() + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# Deprecated client method aliases +# --------------------------------------------------------------------------- + +async def test_start_new_delegates_to_schedule_new_orchestration(): + client = _make_client() + try: + with patch.object(client, "schedule_new_orchestration", + new=AsyncMock(return_value="new-id")) as mock: + with pytest.warns(DeprecationWarning): + result = await client.start_new( + "MyOrchestrator", instance_id="abc", client_input={"x": 1}) + assert result == "new-id" + mock.assert_awaited_once_with( + "MyOrchestrator", input={"x": 1}, instance_id="abc", version=None) + finally: + await client.close() + + +async def test_get_status_delegates_to_get_orchestration_state(): + client = _make_client() + try: + with patch.object(client, "get_orchestration_state", + new=AsyncMock(return_value=None)) as mock: + with pytest.warns(DeprecationWarning): + await client.get_status("abc", show_input=True) + mock.assert_awaited_once_with("abc", fetch_payloads=True) + finally: + await client.close() + + +async def test_get_status_all_delegates(): + client = _make_client() + try: + with patch.object(client, "get_all_orchestration_states", + new=AsyncMock(return_value=[])) as mock: + with pytest.warns(DeprecationWarning): + await client.get_status_all() + mock.assert_awaited_once_with() + finally: + await client.close() + + +async def test_raise_event_delegates(): + client = _make_client() + try: + with patch.object(client, "raise_orchestration_event", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.raise_event("abc", "evt", event_data={"k": "v"}) + mock.assert_awaited_once_with("abc", "evt", data={"k": "v"}) + finally: + await client.close() + + +async def test_terminate_delegates(): + client = _make_client() + try: + with patch.object(client, "terminate_orchestration", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.terminate("abc", "because") + mock.assert_awaited_once_with("abc", output="because") + finally: + await client.close() + + +async def test_purge_instance_history_delegates(): + client = _make_client() + try: + with patch.object(client, "purge_orchestration", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.purge_instance_history("abc") + mock.assert_awaited_once_with("abc") + finally: + await client.close() + + +async def test_suspend_resume_delegate(): + client = _make_client() + try: + with patch.object(client, "suspend_orchestration", + new=AsyncMock()) as suspend_mock: + with pytest.warns(DeprecationWarning): + await client.suspend("abc", "reason") + suspend_mock.assert_awaited_once_with("abc") + + with patch.object(client, "resume_orchestration", + new=AsyncMock()) as resume_mock: + with pytest.warns(DeprecationWarning): + await client.resume("abc", "reason") + resume_mock.assert_awaited_once_with("abc") + finally: + await client.close() + + +async def test_restart_delegates(): + client = _make_client() + try: + with patch.object(client, "restart_orchestration", + new=AsyncMock(return_value="abc")) as mock: + with pytest.warns(DeprecationWarning): + await client.restart("abc") + mock.assert_awaited_once_with("abc", restart_with_new_instance_id=True) + finally: + await client.close() + + +async def test_read_entity_state_delegates_to_get_entity(): + client = _make_client() + try: + with patch.object(client, "get_entity", + new=AsyncMock(return_value=None)) as mock: + with pytest.warns(DeprecationWarning): + await client.read_entity_state("@counter@one") + mock.assert_awaited_once_with("@counter@one") + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# OrchestrationRuntimeStatus mapping +# --------------------------------------------------------------------------- + +def test_orchestration_runtime_status_is_exported(): + assert df.OrchestrationRuntimeStatus.Running.value == "Running" + + +def test_to_durabletask_status_maps_known_values(): + assert to_durabletask_status( + df.OrchestrationRuntimeStatus.Running) == OrchestrationStatus.RUNNING + assert to_durabletask_status( + df.OrchestrationRuntimeStatus.ContinuedAsNew) == OrchestrationStatus.CONTINUED_AS_NEW + + +def test_to_durabletask_status_rejects_canceled(): + with pytest.raises(ValueError): + to_durabletask_status(df.OrchestrationRuntimeStatus.Canceled) + + +def test_to_durabletask_statuses_preserves_none(): + assert to_durabletask_statuses(None) is None + assert to_durabletask_statuses( + [df.OrchestrationRuntimeStatus.Failed]) == [OrchestrationStatus.FAILED] + + +async def test_get_status_by_maps_statuses(): + client = _make_client() + try: + with patch.object(client, "get_all_orchestration_states", + new=AsyncMock(return_value=[])) as mock: + with pytest.warns(DeprecationWarning): + await client.get_status_by( + runtime_status=[df.OrchestrationRuntimeStatus.Running]) + query = mock.await_args.args[0] + assert query.runtime_status == [OrchestrationStatus.RUNNING] + finally: + await client.close() + + +async def test_purge_instance_history_by_maps_statuses(): + client = _make_client() + try: + with patch.object(client, "purge_orchestrations_by", + new=AsyncMock()) as mock: + with pytest.warns(DeprecationWarning): + await client.purge_instance_history_by( + runtime_status=[df.OrchestrationRuntimeStatus.Completed]) + assert mock.await_args.kwargs["runtime_status"] == [OrchestrationStatus.COMPLETED] + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# signal_entity v1 keyword compatibility +# --------------------------------------------------------------------------- + +async def test_signal_entity_accepts_operation_input(): + client = _make_client() + try: + with patch.object(AsyncTaskHubGrpcClient, "signal_entity", + new=AsyncMock()) as mock: + await client.signal_entity( + "@counter@one", "add", operation_input=5, task_hub_name="hub") + mock.assert_awaited_once_with( + "@counter@one", "add", input=5, signal_time=None) + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# wait_for_completion_or_create_check_status_response +# --------------------------------------------------------------------------- + +def _make_request() -> func.HttpRequest: + return func.HttpRequest( + method="GET", url="http://localhost:7071/api/status", body=b"") + + +async def test_wait_for_completion_returns_output_when_completed(): + client = _make_client() + try: + state = SimpleNamespace( + runtime_status=OrchestrationStatus.COMPLETED, + serialized_output='"done"') + with patch.object(client, "wait_for_orchestration_completion", + new=AsyncMock(return_value=state)): + with pytest.warns(DeprecationWarning): + response = await client.wait_for_completion_or_create_check_status_response( + _make_request(), "abc") + assert response.status_code == 200 + assert response.get_body() == b'"done"' + finally: + await client.close() + + +async def test_wait_for_completion_returns_check_status_on_timeout(): + client = _make_client() + try: + with patch.object(client, "wait_for_orchestration_completion", + new=AsyncMock(side_effect=TimeoutError)): + with pytest.warns(DeprecationWarning): + response = await client.wait_for_completion_or_create_check_status_response( + _make_request(), "abc") + assert response.status_code == 202 + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# rewind (not implemented) +# --------------------------------------------------------------------------- + +async def test_rewind_raises_not_implemented(): + client = _make_client() + try: + with pytest.warns(DeprecationWarning): + with pytest.raises(NotImplementedError): + await client.rewind("abc", "reason") + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# get_client_response_links +# --------------------------------------------------------------------------- + +async def test_get_client_response_links_delegates(): + client = _make_client() + try: + with pytest.warns(DeprecationWarning): + payload = client.get_client_response_links(None, "abc") + assert payload.urls["id"] == "abc" + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# Exported class aliases +# --------------------------------------------------------------------------- + +def test_durable_orchestration_client_is_subclass(): + assert issubclass(df.DurableOrchestrationClient, df.DurableFunctionsClient) + + +def test_entity_id_maps_to_entity_instance_id(): + with pytest.warns(DeprecationWarning): + entity_id = df.EntityId("Counter", "one") + assert isinstance(entity_id, EntityInstanceId) + assert entity_id.name == "counter" + assert str(entity_id) == "@counter@one" + + +def test_managed_identity_token_source_shim(): + with pytest.warns(DeprecationWarning): + source = df.ManagedIdentityTokenSource("https://management.core.windows.net") + assert source.resource == "https://management.core.windows.net" + assert source.to_json()["kind"] == "AzureManagedIdentity" + + +def test_entity_class_raises_not_implemented(): + with pytest.warns(DeprecationWarning): + with pytest.raises(NotImplementedError): + df.Entity(lambda ctx: None) + + +def test_context_aliases_are_subclasses(): + from durabletask.entities import EntityContext + from durabletask.task import OrchestrationContext + assert issubclass(df.DurableOrchestrationContext, OrchestrationContext) + assert issubclass(df.DurableEntityContext, EntityContext) + + +# --------------------------------------------------------------------------- +# Return-type shims: DurableOrchestrationStatus +# --------------------------------------------------------------------------- + +def _fake_state(): + return SimpleNamespace( + name="orch", + instance_id="abc", + created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + last_updated_at=datetime(2026, 1, 2, tzinfo=timezone.utc), + runtime_status=OrchestrationStatus.RUNNING, + get_input=lambda: {"in": 1}, + get_output=lambda: {"out": 2}, + get_custom_status=lambda: "cs", + ) + + +def test_from_durabletask_status_reverse_mapping(): + assert from_durabletask_status(OrchestrationStatus.RUNNING) == df.OrchestrationRuntimeStatus.Running + assert from_durabletask_status( + OrchestrationStatus.CONTINUED_AS_NEW) == df.OrchestrationRuntimeStatus.ContinuedAsNew + + +async def test_get_status_returns_wrapped_status(): + client = _make_client() + try: + with patch.object(client, "get_orchestration_state", + new=AsyncMock(return_value=_fake_state())): + with pytest.warns(DeprecationWarning): + status = await client.get_status("abc") + assert bool(status) is True + assert status.name == "orch" + assert status.instance_id == "abc" + assert status.runtime_status == df.OrchestrationRuntimeStatus.Running + assert status.input_ == {"in": 1} + assert status.output == {"out": 2} + assert status.custom_status == "cs" + assert status.to_json()["runtimeStatus"] == "Running" + finally: + await client.close() + + +async def test_get_status_missing_instance_is_falsy(): + client = _make_client() + try: + with patch.object(client, "get_orchestration_state", + new=AsyncMock(return_value=None)): + with pytest.warns(DeprecationWarning): + status = await client.get_status("missing") + assert bool(status) is False + assert status.runtime_status is None + assert status.output is None + finally: + await client.close() + + +async def test_get_status_all_returns_wrapped_list(): + client = _make_client() + try: + with patch.object(client, "get_all_orchestration_states", + new=AsyncMock(return_value=[_fake_state()])): + with pytest.warns(DeprecationWarning): + statuses = await client.get_status_all() + assert len(statuses) == 1 + assert statuses[0].runtime_status == df.OrchestrationRuntimeStatus.Running + finally: + await client.close() + + +async def test_get_status_by_returns_wrapped_list(): + client = _make_client() + try: + with patch.object(client, "get_all_orchestration_states", + new=AsyncMock(return_value=[_fake_state()])): + with pytest.warns(DeprecationWarning): + statuses = await client.get_status_by( + runtime_status=[df.OrchestrationRuntimeStatus.Running]) + assert statuses[0].instance_id == "abc" + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# Return-type shims: PurgeHistoryResult +# --------------------------------------------------------------------------- + +async def test_purge_instance_history_returns_purge_history_result(): + client = _make_client() + try: + result = SimpleNamespace(deleted_instance_count=3, is_complete=True) + with patch.object(client, "purge_orchestration", + new=AsyncMock(return_value=result)): + with pytest.warns(DeprecationWarning): + purge = await client.purge_instance_history("abc") + assert purge.instances_deleted == 3 + finally: + await client.close() + + +async def test_purge_instance_history_by_returns_purge_history_result(): + client = _make_client() + try: + result = SimpleNamespace(deleted_instance_count=5, is_complete=True) + with patch.object(client, "purge_orchestrations_by", + new=AsyncMock(return_value=result)): + with pytest.warns(DeprecationWarning): + purge = await client.purge_instance_history_by( + runtime_status=[df.OrchestrationRuntimeStatus.Completed]) + assert purge.instances_deleted == 5 + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# Return-type shims: EntityStateResponse +# --------------------------------------------------------------------------- + +async def test_read_entity_state_wraps_metadata_when_present(): + client = _make_client() + try: + metadata = SimpleNamespace( + includes_state=True, get_typed_state=lambda: {"count": 5}) + with patch.object(client, "get_entity", + new=AsyncMock(return_value=metadata)): + with pytest.warns(DeprecationWarning): + response = await client.read_entity_state("@counter@one") + assert response.entity_exists is True + assert response.entity_state == {"count": 5} + finally: + await client.close() + + +async def test_read_entity_state_when_missing(): + client = _make_client() + try: + with patch.object(client, "get_entity", + new=AsyncMock(return_value=None)): + with pytest.warns(DeprecationWarning): + response = await client.read_entity_state("@counter@one") + assert response.entity_exists is False + assert response.entity_state is None + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# HttpManagementPayload dict-like access +# --------------------------------------------------------------------------- + +async def test_http_management_payload_is_mapping_like(): + client = _make_client() + try: + payload = client.create_http_management_payload("inst1") + assert payload["id"] == "inst1" + assert "statusQueryGetUri" in payload + assert "id" in list(payload.keys()) + assert dict(payload.items())["id"] == "inst1" + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# call_http not implemented +# --------------------------------------------------------------------------- + +def test_call_http_raises_not_implemented(): + # call_http ignores self, so invoke via the class to avoid instantiating + # the abstract context. + with pytest.raises(NotImplementedError): + df.DurableOrchestrationContext.call_http(None, "GET", "http://example.com") + + +def test_token_source_is_still_constructible(): + with pytest.warns(DeprecationWarning): + source = df.ManagedIdentityTokenSource("https://graph.microsoft.com") + assert source.resource == "https://graph.microsoft.com" From d4a84bc1ef5c7c1d9329d0b054056c15f990ceea Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 11:41:38 -0600 Subject: [PATCH 27/33] Reorganize compat files --- .../azure/durable_functions/__init__.py | 16 ++++++++-------- .../azure/durable_functions/client.py | 8 ++++---- .../internal/compat/__init__.py | 8 ++++++++ .../{ => internal/compat}/compat_aliases.py | 2 +- .../compat}/durable_orchestration_status.py | 0 .../{ => internal/compat}/entity_id.py | 0 .../compat}/entity_state_response.py | 0 .../compat}/orchestration_runtime_status.py | 0 .../compat}/purge_history_result.py | 0 .../{ => internal/compat}/retry_options.py | 0 .../{ => internal/compat}/token_source.py | 0 .../test_client_compat.py | 2 +- 12 files changed, 22 insertions(+), 14 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/internal/compat/__init__.py rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/compat_aliases.py (98%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/durable_orchestration_status.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/entity_id.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/entity_state_response.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/orchestration_runtime_status.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/purge_history_result.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/retry_options.py (100%) rename azure-functions-durable/azure/durable_functions/{ => internal/compat}/token_source.py (100%) diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index e415b137..f917d5de 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -9,14 +9,14 @@ from .decorators.durable_app import Blueprint, DFApp from .client import DurableFunctionsClient from .orchestrator import Orchestrator -from .retry_options import RetryOptions -from .orchestration_runtime_status import OrchestrationRuntimeStatus -from .durable_orchestration_status import DurableOrchestrationStatus -from .purge_history_result import PurgeHistoryResult -from .entity_state_response import EntityStateResponse -from .entity_id import EntityId -from .token_source import ManagedIdentityTokenSource, TokenSource -from .compat_aliases import ( +from .internal.compat.retry_options import RetryOptions +from .internal.compat.orchestration_runtime_status import OrchestrationRuntimeStatus +from .internal.compat.durable_orchestration_status import DurableOrchestrationStatus +from .internal.compat.purge_history_result import PurgeHistoryResult +from .internal.compat.entity_state_response import EntityStateResponse +from .internal.compat.entity_id import EntityId +from .internal.compat.token_source import ManagedIdentityTokenSource, TokenSource +from .internal.compat.compat_aliases import ( DurableEntityContext, DurableOrchestrationClient, DurableOrchestrationContext, diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index c98048ae..fda5df28 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -18,10 +18,10 @@ from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload -from .durable_orchestration_status import DurableOrchestrationStatus -from .entity_state_response import EntityStateResponse -from .orchestration_runtime_status import OrchestrationRuntimeStatus, to_durabletask_statuses -from .purge_history_result import PurgeHistoryResult +from .internal.compat.durable_orchestration_status import DurableOrchestrationStatus +from .internal.compat.entity_state_response import EntityStateResponse +from .internal.compat.orchestration_runtime_status import OrchestrationRuntimeStatus, to_durabletask_statuses +from .internal.compat.purge_history_result import PurgeHistoryResult # Client class used for Durable Functions diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/__init__.py b/azure-functions-durable/azure/durable_functions/internal/compat/__init__.py new file mode 100644 index 00000000..72b690df --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/__init__.py @@ -0,0 +1,8 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +"""Backwards-compatibility shims for the v1 azure-functions-durable API. + +The public names defined here are re-exported from ``azure.durable_functions``; +import them from there rather than from this internal package. +""" diff --git a/azure-functions-durable/azure/durable_functions/compat_aliases.py b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py similarity index 98% rename from azure-functions-durable/azure/durable_functions/compat_aliases.py rename to azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py index 1d53a17b..79a440b5 100644 --- a/azure-functions-durable/azure/durable_functions/compat_aliases.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py @@ -8,7 +8,7 @@ from durabletask.entities import EntityContext from durabletask.task import OrchestrationContext -from .client import DurableFunctionsClient +from ...client import DurableFunctionsClient from .token_source import TokenSource diff --git a/azure-functions-durable/azure/durable_functions/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/durable_orchestration_status.py rename to azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py diff --git a/azure-functions-durable/azure/durable_functions/entity_id.py b/azure-functions-durable/azure/durable_functions/internal/compat/entity_id.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/entity_id.py rename to azure-functions-durable/azure/durable_functions/internal/compat/entity_id.py diff --git a/azure-functions-durable/azure/durable_functions/entity_state_response.py b/azure-functions-durable/azure/durable_functions/internal/compat/entity_state_response.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/entity_state_response.py rename to azure-functions-durable/azure/durable_functions/internal/compat/entity_state_response.py diff --git a/azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_runtime_status.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/orchestration_runtime_status.py rename to azure-functions-durable/azure/durable_functions/internal/compat/orchestration_runtime_status.py diff --git a/azure-functions-durable/azure/durable_functions/purge_history_result.py b/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/purge_history_result.py rename to azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py diff --git a/azure-functions-durable/azure/durable_functions/retry_options.py b/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/retry_options.py rename to azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py diff --git a/azure-functions-durable/azure/durable_functions/token_source.py b/azure-functions-durable/azure/durable_functions/internal/compat/token_source.py similarity index 100% rename from azure-functions-durable/azure/durable_functions/token_source.py rename to azure-functions-durable/azure/durable_functions/internal/compat/token_source.py diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index fa18d2e1..5ee91ed2 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -11,7 +11,7 @@ import azure.durable_functions as df from azure.durable_functions import RetryOptions -from azure.durable_functions.orchestration_runtime_status import ( +from azure.durable_functions.internal.compat.orchestration_runtime_status import ( from_durabletask_status, to_durabletask_status, to_durabletask_statuses, From a1b54bd8a76245630da992caae5e9969fe548ac5 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 12:08:34 -0600 Subject: [PATCH 28/33] Add shims for old orchestration and entity call arg patterns --- azure-functions-durable/CHANGELOG.md | 23 ++ .../azure/durable_functions/__init__.py | 4 +- .../internal/compat/compat_aliases.py | 42 +--- .../internal/compat/entity_context.py | 116 ++++++++++ .../internal/compat/orchestration_context.py | 200 ++++++++++++++++++ .../azure/durable_functions/worker.py | 6 +- .../test_client_compat.py | 7 - .../test_entity_context_compat.py | 128 +++++++++++ .../test_orchestration_context_compat.py | 180 ++++++++++++++++ 9 files changed, 654 insertions(+), 52 deletions(-) create mode 100644 azure-functions-durable/azure/durable_functions/internal/compat/entity_context.py create mode 100644 azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py create mode 100644 tests/azure-functions-durable/test_entity_context_compat.py create mode 100644 tests/azure-functions-durable/test_orchestration_context_compat.py diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 6dc0ece5..f5856673 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -9,6 +9,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- One-argument (Azure Functions / v1-style) entity functions + (``def entity(context):``) are now supported. The worker detects the entity's + shape and, for single-argument functions, delivers a functional + `DurableEntityContext` that wraps the durabletask `EntityContext` and exposes + the v1 entity API: `entity_name`, `entity_key`, `operation_name`, + `get_input()`, `get_state()` (with `initializer`), `set_state()`, + `set_result()`, and `destruct_on_exit()`. The operation result is taken from + `set_result(...)`, falling back to the function's return value. + durabletask-native two-argument entity functions and class-based + (`DurableEntity`) entities continue to work unchanged. +- One-argument (Azure Functions / v1-style) orchestrator functions + (``def orchestrator(context):``) are now supported. The worker detects the + orchestrator's arity and, for single-argument functions, delivers a + functional `DurableOrchestrationContext` that wraps the durabletask + `OrchestrationContext` and exposes the v1 context API: `get_input()`, + `call_activity`/`call_activity_with_retry`, + `call_sub_orchestrator`/`call_sub_orchestrator_with_retry`, `create_timer`, + `wait_for_external_event`, `continue_as_new`, `set_custom_status`, + `task_all`/`task_any`, `call_entity`/`signal_entity`, and `new_uuid`/`new_guid`. + Two-argument (durabletask-native) orchestrators continue to work unchanged. + `DurableOrchestrationContext.call_http` raises `NotImplementedError` pending a + durabletask durable-HTTP implementation. + - Backwards-compatible, deprecated aliases on `DurableFunctionsClient` for the v1 `DurableOrchestrationClient` method names: `start_new`, `get_status`, `get_status_all`, `get_status_by`, `raise_event`, `terminate`, diff --git a/azure-functions-durable/azure/durable_functions/__init__.py b/azure-functions-durable/azure/durable_functions/__init__.py index f917d5de..b24c353e 100644 --- a/azure-functions-durable/azure/durable_functions/__init__.py +++ b/azure-functions-durable/azure/durable_functions/__init__.py @@ -16,10 +16,10 @@ from .internal.compat.entity_state_response import EntityStateResponse from .internal.compat.entity_id import EntityId from .internal.compat.token_source import ManagedIdentityTokenSource, TokenSource +from .internal.compat.orchestration_context import DurableOrchestrationContext +from .internal.compat.entity_context import DurableEntityContext from .internal.compat.compat_aliases import ( - DurableEntityContext, DurableOrchestrationClient, - DurableOrchestrationContext, Entity, ) diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py index 79a440b5..ddfcf923 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/compat_aliases.py @@ -1,15 +1,11 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. -from typing import Any, Optional +from typing import Any from typing_extensions import deprecated -from durabletask.entities import EntityContext -from durabletask.task import OrchestrationContext - from ...client import DurableFunctionsClient -from .token_source import TokenSource @deprecated( @@ -18,42 +14,6 @@ class DurableOrchestrationClient(DurableFunctionsClient): """Deprecated alias for :class:`DurableFunctionsClient`.""" -@deprecated( - "DurableOrchestrationContext is deprecated; use " - "durabletask.task.OrchestrationContext instead.") -class DurableOrchestrationContext(OrchestrationContext): - """Deprecated alias for :class:`durabletask.task.OrchestrationContext`. - - Retained so v1 type hints (``def my_orchestrator(context: DurableOrchestrationContext)``) - continue to import. At runtime the durabletask worker passes an - ``OrchestrationContext`` instance. - """ - - def call_http(self, - method: str, - uri: str, - content: Optional[str] = None, - headers: Optional[dict[str, str]] = None, - token_source: Optional[TokenSource] = None, - is_raw_str: bool = False) -> Any: - """Schedule a durable HTTP call (v1 API). - - Not yet supported: durabletask has no durable-HTTP (``call_http``) - equivalent, so this raises ``NotImplementedError``. It is present to - document the v1 API surface and the current gap. - """ - raise NotImplementedError( - "call_http is not yet supported by durabletask. The durable-HTTP " - "API (and its TokenSource auth) has no durabletask equivalent yet.") - - -@deprecated( - "DurableEntityContext is deprecated; use " - "durabletask.entities.EntityContext instead.") -class DurableEntityContext(EntityContext): - """Deprecated alias for :class:`durabletask.entities.EntityContext`.""" - - @deprecated( "The Entity class is deprecated and unsupported in v2; register entities " "with the entity_trigger decorator instead.") diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/entity_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/entity_context.py new file mode 100644 index 00000000..d2043a3f --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/entity_context.py @@ -0,0 +1,116 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from typing import Any, Callable, Optional + +from durabletask.entities import EntityContext + +from .orchestration_context import accepts_two_positional_args + + +class DurableEntityContext: + """Azure Functions-style entity context (v1-compatible). + + Wraps a durabletask :class:`~durabletask.entities.EntityContext` (and the + operation input) and exposes the v1 ``DurableEntityContext`` API. It is + delivered to one-argument entity functions (``def entity(context):``). + durabletask-native two-argument entity functions + (``def entity(ctx, input):``) and class-based entities are used directly. + """ + + def __init__(self, ctx: EntityContext, operation_input: Any = None): + self._ctx = ctx + self._input = operation_input + self._result: Any = None + + # -- identity ------------------------------------------------------------ + @property + def entity_name(self) -> str: + """Get the entity name.""" + return self._ctx.entity_id.entity + + @property + def entity_key(self) -> str: + """Get the entity key.""" + return self._ctx.entity_id.key + + @property + def operation_name(self) -> str: + """Get the current operation name.""" + return self._ctx.operation + + @property + def is_newly_constructed(self) -> bool: + """Whether the entity was newly constructed. + + The v1 semantics of this flag were unspecified; it is always ``False``. + """ + return False + + # -- input / state / result --------------------------------------------- + def get_input(self, expected_type: Optional[type] = None) -> Any: + """Get the input for the current operation. + + ``expected_type`` is accepted for v1 compatibility but the input is + already deserialized by durabletask, so it is returned as-is. + """ + return self._input + + def get_state(self, + initializer: Optional[Callable[[], Any]] = None, + expected_type: Optional[type] = None) -> Any: + """Get the current state of the entity. + + Parameters + ---------- + initializer : Optional[Callable[[], Any]] + A zero-argument callable providing the initial state when no state + exists yet. + expected_type : Optional[type] + Optional type used to reconstruct the state. + """ + default = initializer() if callable(initializer) else None + return self._ctx.get_state(expected_type, default) + + def set_state(self, state: Any) -> None: + """Set the state of the entity.""" + self._ctx.set_state(state) + + def set_result(self, result: Any) -> None: + """Set the result (return value) of the current operation.""" + self._result = result + + def resolve_result(self, fallback: Any) -> Any: + """Return the value set via :meth:`set_result`, or ``fallback`` if unset.""" + return self._result if self._result is not None else fallback + + def destruct_on_exit(self) -> None: + """Delete this entity after the operation completes.""" + self._ctx.set_state(None) + + +def wrap_entity(fn: Callable[..., Any]) -> Callable[..., Any]: + """Adapt a v1-style one-argument entity function to durabletask's ``(ctx, input)``. + + Class-based entities and durabletask-native two-argument entity functions + are returned unchanged. For a wrapped v1 entity, the operation result is + taken from ``context.set_result(...)`` (falling back to the function's + return value). + """ + if isinstance(fn, type): + # Class-based entity: handled natively by durabletask. + return fn + if accepts_two_positional_args(fn): + # durabletask-native (ctx, input) entity function. + return fn + + def _wrapper(ctx: EntityContext, _input: Any = None) -> Any: + adapter = DurableEntityContext(ctx, _input) + returned = fn(adapter) + return adapter.resolve_result(returned) + + _wrapper.__name__ = getattr(fn, "__name__", "entity") + durable_entity_name = getattr(fn, "__durable_entity_name__", None) + if durable_entity_name is not None: + _wrapper.__durable_entity_name__ = durable_entity_name # type: ignore[attr-defined] + return _wrapper diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py new file mode 100644 index 00000000..9cfb7a12 --- /dev/null +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -0,0 +1,200 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import inspect +from datetime import datetime +from typing import Any, Callable, Generator, Optional, cast +from uuid import UUID + +from durabletask import task +from durabletask.entities import EntityInstanceId +from durabletask.task import OrchestrationContext, RetryPolicy, Task + +from .token_source import TokenSource + + +class DurableOrchestrationContext: + """Azure Functions-style orchestration context (v1-compatible). + + Wraps a durabletask :class:`~durabletask.task.OrchestrationContext` (and the + orchestration input) and exposes the v1 ``DurableOrchestrationContext`` API. + It is delivered to one-argument orchestrator functions + (``def orchestrator(context):``); durabletask-native two-argument + orchestrators (``def orchestrator(ctx, input):``) receive the durabletask + context directly instead. + """ + + def __init__(self, ctx: OrchestrationContext, orchestration_input: Any = None): + self._ctx = ctx + self._input = orchestration_input + + # -- input --------------------------------------------------------------- + def get_input(self) -> Any: + """Get the orchestration input.""" + return self._input + + # -- properties ---------------------------------------------------------- + @property + def instance_id(self) -> str: + """Get the ID of the current orchestration instance.""" + return self._ctx.instance_id + + @property + def is_replaying(self) -> bool: + """Get whether the orchestrator is currently replaying.""" + return self._ctx.is_replaying + + @property + def current_utc_datetime(self) -> datetime: + """Get the replay-safe current UTC date/time.""" + return self._ctx.current_utc_datetime + + # -- activities ---------------------------------------------------------- + def call_activity(self, name: Callable[..., Any] | str, input_: Any = None) -> Task[Any]: + """Schedule an activity function for execution.""" + return self._ctx.call_activity(name, input=input_) + + def call_activity_with_retry(self, + name: Callable[..., Any] | str, + retry_options: RetryPolicy, + input_: Any = None) -> Task[Any]: + """Schedule an activity function for execution, with retries.""" + return self._ctx.call_activity(name, input=input_, retry_policy=retry_options) + + # -- sub-orchestrators --------------------------------------------------- + def call_sub_orchestrator(self, + name: Callable[..., Any] | str, + input_: Any = None, + instance_id: Optional[str] = None) -> Task[Any]: + """Schedule a sub-orchestrator function for execution.""" + return self._ctx.call_sub_orchestrator(name, input=input_, instance_id=instance_id) + + def call_sub_orchestrator_with_retry(self, + name: Callable[..., Any] | str, + retry_options: RetryPolicy, + input_: Any = None, + instance_id: Optional[str] = None) -> Task[Any]: + """Schedule a sub-orchestrator function for execution, with retries.""" + return self._ctx.call_sub_orchestrator( + name, input=input_, instance_id=instance_id, retry_policy=retry_options) + + # -- timers and events --------------------------------------------------- + def create_timer(self, fire_at: datetime) -> Task[Any]: + """Create a durable timer that fires at the specified time.""" + return self._ctx.create_timer(fire_at) + + def wait_for_external_event(self, + name: str, + expected_type: Optional[type] = None) -> Task[Any]: + """Wait for an external event with the given name.""" + return self._ctx.wait_for_external_event(name, data_type=expected_type) + + # -- control ------------------------------------------------------------- + def continue_as_new(self, input_: Any) -> None: + """Restart the orchestration with a new input.""" + self._ctx.continue_as_new(input_) + + def set_custom_status(self, status: Any) -> None: + """Set the orchestration's custom status payload.""" + self._ctx.set_custom_status(status) + + # -- deterministic IDs --------------------------------------------------- + def new_uuid(self) -> str: + """Create a new replay-safe UUID string.""" + return self._ctx.new_uuid() + + def new_guid(self) -> UUID: + """Create a new replay-safe UUID.""" + return UUID(self._ctx.new_uuid()) + + # -- fan-out / fan-in ---------------------------------------------------- + def task_all(self, tasks: list[Task[Any]]) -> Task[Any]: + """Schedule all tasks and complete when all of them complete.""" + return task.when_all(tasks) + + def task_any(self, tasks: list[Task[Any]]) -> Task[Any]: + """Schedule all tasks and complete when the first one completes.""" + return task.when_any(tasks) + + # -- entities ------------------------------------------------------------ + def call_entity(self, + entityId: EntityInstanceId, + operationName: str, + operationInput: Any = None) -> Task[Any]: + """Call an entity operation and get its result.""" + return self._ctx.call_entity(entityId, operationName, operationInput) + + def signal_entity(self, + entityId: EntityInstanceId, + operationName: str, + operationInput: Any = None) -> None: + """Signal an entity operation (fire and forget).""" + self._ctx.signal_entity(entityId, operationName, input=operationInput) + + # -- durable HTTP (not yet supported) ------------------------------------ + def call_http(self, + method: str, + uri: str, + content: Optional[str] = None, + headers: Optional[dict[str, str]] = None, + token_source: Optional[TokenSource] = None, + is_raw_str: bool = False) -> Any: + """Schedule a durable HTTP call (v1 API). + + Not yet supported: durabletask has no durable-HTTP (``call_http``) + equivalent, so this raises ``NotImplementedError``. + """ + raise NotImplementedError( + "call_http is not yet supported by durabletask. The durable-HTTP " + "API (and its TokenSource auth) has no durabletask equivalent yet.") + + +def accepts_two_positional_args(fn: Callable[..., Any]) -> bool: + """Return True if ``fn`` can be called with two positional args ``(ctx, input)``. + + Two-argument functions are treated as durabletask-native orchestrators; + single-argument functions are treated as Azure Functions / v1-style + orchestrators that receive a wrapped :class:`DurableOrchestrationContext`. + """ + try: + sig = inspect.signature(fn) + except (TypeError, ValueError): + # Can't introspect -> assume durabletask-native and pass through. + return True + + positional = 0 + for param in sig.parameters.values(): + if param.kind in (param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD): + positional += 1 + elif param.kind == param.VAR_POSITIONAL: + return True + return positional >= 2 + + +def wrap_orchestrator(fn: Callable[..., Any]) -> Callable[..., Any]: + """Adapt a v1-style one-argument orchestrator to durabletask's ``(ctx, input)``. + + Two-argument (durabletask-native) orchestrators are returned unchanged. The + returned wrapper deliberately does not set ``__wrapped__`` so durabletask + introspects the wrapper's own ``(context, _input)`` signature (and thus + passes the raw input) rather than the wrapped function's signature. + """ + if accepts_two_positional_args(fn): + return fn + + name = getattr(fn, "__name__", "orchestrator") + + if inspect.isgeneratorfunction(fn): + def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any: + adapter = DurableOrchestrationContext(context, _input) + generator = cast("Generator[Any, Any, Any]", fn(adapter)) + result: Any = yield from generator + return result + _generator_wrapper.__name__ = name + return _generator_wrapper + + def _wrapper(context: OrchestrationContext, _input: Any = None) -> Any: + adapter = DurableOrchestrationContext(context, _input) + return fn(adapter) + _wrapper.__name__ = name + return _wrapper diff --git a/azure-functions-durable/azure/durable_functions/worker.py b/azure-functions-durable/azure/durable_functions/worker.py index 1e972141..72271f03 100644 --- a/azure-functions-durable/azure/durable_functions/worker.py +++ b/azure-functions-durable/azure/durable_functions/worker.py @@ -14,6 +14,8 @@ ) from durabletask.worker import TaskHubGrpcWorker from .internal.azurefunctions_null_stub import AzureFunctionsNullStub +from .internal.compat.entity_context import wrap_entity +from .internal.compat.orchestration_context import wrap_orchestrator from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER @@ -65,7 +67,7 @@ def stub_complete(stub_response: OrchestratorResponse) -> None: raise Exception("No ExecutionStarted event found in orchestration request.") function_name = execution_started_events[-1].executionStarted.name - self.add_named_orchestrator(function_name, func) + self.add_named_orchestrator(function_name, wrap_orchestrator(func)) super()._execute_orchestrator(request, stub, None) if response is None: @@ -88,7 +90,7 @@ def stub_complete(stub_response: EntityBatchResult) -> None: response = stub_response stub.CompleteEntityTask = stub_complete - self.add_entity(func) + self.add_entity(wrap_entity(func)) super()._execute_entity_batch(request, stub, None) if response is None: diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index 5ee91ed2..fcdb17c2 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -388,13 +388,6 @@ def test_entity_class_raises_not_implemented(): df.Entity(lambda ctx: None) -def test_context_aliases_are_subclasses(): - from durabletask.entities import EntityContext - from durabletask.task import OrchestrationContext - assert issubclass(df.DurableOrchestrationContext, OrchestrationContext) - assert issubclass(df.DurableEntityContext, EntityContext) - - # --------------------------------------------------------------------------- # Return-type shims: DurableOrchestrationStatus # --------------------------------------------------------------------------- diff --git a/tests/azure-functions-durable/test_entity_context_compat.py b/tests/azure-functions-durable/test_entity_context_compat.py new file mode 100644 index 00000000..807846cd --- /dev/null +++ b/tests/azure-functions-durable/test_entity_context_compat.py @@ -0,0 +1,128 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from unittest.mock import MagicMock + +from durabletask.entities import DurableEntity + +from azure.durable_functions.internal.compat.entity_context import ( + DurableEntityContext, + wrap_entity, +) + + +def _adapter(operation_input=None): + fake_ctx = MagicMock() + fake_ctx.entity_id.entity = "counter" + fake_ctx.entity_id.key = "k1" + fake_ctx.operation = "add" + return DurableEntityContext(fake_ctx, operation_input), fake_ctx + + +# --------------------------------------------------------------------------- +# Adapter delegation +# --------------------------------------------------------------------------- + +def test_identity_properties(): + adapter, _ = _adapter() + assert adapter.entity_name == "counter" + assert adapter.entity_key == "k1" + assert adapter.operation_name == "add" + assert adapter.is_newly_constructed is False + + +def test_get_input_returns_stored_input(): + adapter, _ = _adapter(5) + assert adapter.get_input() == 5 + + +def test_get_state_maps_initializer_to_default(): + adapter, fake = _adapter() + fake.get_state.return_value = 0 + result = adapter.get_state(initializer=lambda: 0, expected_type=int) + assert result == 0 + fake.get_state.assert_called_once_with(int, 0) + + +def test_get_state_without_initializer(): + adapter, fake = _adapter() + adapter.get_state() + fake.get_state.assert_called_once_with(None, None) + + +def test_set_state_delegates(): + adapter, fake = _adapter() + adapter.set_state({"count": 3}) + fake.set_state.assert_called_once_with({"count": 3}) + + +def test_destruct_on_exit_clears_state(): + adapter, fake = _adapter() + adapter.destruct_on_exit() + fake.set_state.assert_called_once_with(None) + + +# --------------------------------------------------------------------------- +# wrap_entity +# --------------------------------------------------------------------------- + +def test_wrap_passes_through_two_arg_entity(): + def entity(ctx, inp): + return None + assert wrap_entity(entity) is entity + + +def test_wrap_passes_through_class_based_entity(): + class Counter(DurableEntity): + def add(self, amount): + return amount + assert wrap_entity(Counter) is Counter + + +def test_wrap_adapts_one_arg_entity_with_set_result(): + seen = {} + + def counter_entity(context): + seen["op"] = context.operation_name + seen["input"] = context.get_input() + current = context.get_state(initializer=lambda: 0) + context.set_state(current + context.get_input()) + context.set_result(current + context.get_input()) + + wrapped = wrap_entity(counter_entity) + assert wrapped is not counter_entity + + fake_ctx = MagicMock() + fake_ctx.entity_id.entity = "counter" + fake_ctx.entity_id.key = "k1" + fake_ctx.operation = "add" + fake_ctx.get_state.return_value = 10 + + result = wrapped(fake_ctx, 5) + assert result == 15 + assert seen["op"] == "add" + assert seen["input"] == 5 + fake_ctx.set_state.assert_called_once_with(15) + + +def test_wrap_adapts_one_arg_entity_falls_back_to_return_value(): + def entity(context): + return "returned" + + wrapped = wrap_entity(entity) + fake_ctx = MagicMock() + assert wrapped(fake_ctx, None) == "returned" + + +def test_wrap_preserves_entity_name(): + def my_entity(context): + return None + assert wrap_entity(my_entity).__name__ == "my_entity" + + +def test_wrap_preserves_durable_entity_name(): + def entity_fn(context): + return None + entity_fn.__durable_entity_name__ = "CustomName" + wrapped = wrap_entity(entity_fn) + assert wrapped.__durable_entity_name__ == "CustomName" diff --git a/tests/azure-functions-durable/test_orchestration_context_compat.py b/tests/azure-functions-durable/test_orchestration_context_compat.py new file mode 100644 index 00000000..119e6c29 --- /dev/null +++ b/tests/azure-functions-durable/test_orchestration_context_compat.py @@ -0,0 +1,180 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +from unittest.mock import MagicMock, patch +from uuid import UUID + +import pytest + +from azure.durable_functions.internal.compat.orchestration_context import ( + DurableOrchestrationContext, + accepts_two_positional_args, + wrap_orchestrator, +) + + +# --------------------------------------------------------------------------- +# Adapter delegation +# --------------------------------------------------------------------------- + +def _adapter(orchestration_input=None): + fake_ctx = MagicMock() + fake_ctx.instance_id = "iid" + fake_ctx.is_replaying = True + return DurableOrchestrationContext(fake_ctx, orchestration_input), fake_ctx + + +def test_get_input_returns_stored_input(): + adapter, _ = _adapter({"x": 1}) + assert adapter.get_input() == {"x": 1} + + +def test_property_delegation(): + adapter, fake = _adapter() + assert adapter.instance_id == "iid" + assert adapter.is_replaying is True + assert adapter.current_utc_datetime is fake.current_utc_datetime + + +def test_call_activity_delegates(): + adapter, fake = _adapter() + adapter.call_activity("A", input_=3) + fake.call_activity.assert_called_once_with("A", input=3) + + +def test_call_activity_with_retry_delegates(): + adapter, fake = _adapter() + retry = object() + adapter.call_activity_with_retry("A", retry, input_=4) + fake.call_activity.assert_called_once_with("A", input=4, retry_policy=retry) + + +def test_call_sub_orchestrator_delegates(): + adapter, fake = _adapter() + adapter.call_sub_orchestrator("Sub", input_=1, instance_id="sid") + fake.call_sub_orchestrator.assert_called_once_with("Sub", input=1, instance_id="sid") + + +def test_call_sub_orchestrator_with_retry_delegates(): + adapter, fake = _adapter() + retry = object() + adapter.call_sub_orchestrator_with_retry("Sub", retry, input_=1, instance_id="sid") + fake.call_sub_orchestrator.assert_called_once_with( + "Sub", input=1, instance_id="sid", retry_policy=retry) + + +def test_wait_for_external_event_maps_expected_type(): + adapter, fake = _adapter() + adapter.wait_for_external_event("evt", expected_type=str) + fake.wait_for_external_event.assert_called_once_with("evt", data_type=str) + + +def test_create_timer_delegates(): + adapter, fake = _adapter() + adapter.create_timer("fire_at") + fake.create_timer.assert_called_once_with("fire_at") + + +def test_continue_as_new_and_set_custom_status_delegate(): + adapter, fake = _adapter() + adapter.continue_as_new({"n": 1}) + fake.continue_as_new.assert_called_once_with({"n": 1}) + adapter.set_custom_status("status") + fake.set_custom_status.assert_called_once_with("status") + + +def test_entity_operations_delegate(): + adapter, fake = _adapter() + adapter.call_entity("@e@k", "op", 1) + fake.call_entity.assert_called_once_with("@e@k", "op", 1) + adapter.signal_entity("@e@k", "op", 2) + fake.signal_entity.assert_called_once_with("@e@k", "op", input=2) + + +def test_new_uuid_and_new_guid(): + adapter, fake = _adapter() + fake.new_uuid.return_value = "12345678-1234-5678-1234-567812345678" + assert adapter.new_uuid() == "12345678-1234-5678-1234-567812345678" + guid = adapter.new_guid() + assert isinstance(guid, UUID) + assert str(guid) == "12345678-1234-5678-1234-567812345678" + + +def test_task_all_and_task_any_use_when_helpers(): + adapter, _ = _adapter() + with patch("durabletask.task.when_all", return_value="ALL") as when_all, \ + patch("durabletask.task.when_any", return_value="ANY") as when_any: + assert adapter.task_all(["t1", "t2"]) == "ALL" + assert adapter.task_any(["t1", "t2"]) == "ANY" + when_all.assert_called_once_with(["t1", "t2"]) + when_any.assert_called_once_with(["t1", "t2"]) + + +def test_call_http_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + adapter.call_http("GET", "http://example.com") + + +# --------------------------------------------------------------------------- +# Arity detection and wrapping +# --------------------------------------------------------------------------- + +def test_accepts_two_positional_args(): + assert accepts_two_positional_args(lambda ctx, inp: None) is True + assert accepts_two_positional_args(lambda ctx: None) is False + assert accepts_two_positional_args(lambda *args: None) is True + + +def test_wrap_passes_through_two_arg_orchestrator(): + def orch(ctx, inp): + return None + assert wrap_orchestrator(orch) is orch + + +def test_wrap_adapts_one_arg_non_generator(): + seen = {} + + def orch(context): + seen["input"] = context.get_input() + return "done" + + wrapped = wrap_orchestrator(orch) + assert wrapped is not orch + fake_ctx = MagicMock() + result = wrapped(fake_ctx, 42) + assert result == "done" + assert seen["input"] == 42 + + +def test_wrap_adapts_one_arg_generator_end_to_end(): + seen = {} + + def orch(context): + seen["input"] = context.get_input() + activity_result = yield context.call_activity("A", input_=5) + seen["activity_result"] = activity_result + return activity_result * 2 + + wrapped = wrap_orchestrator(orch) + fake_ctx = MagicMock() + fake_ctx.call_activity.return_value = "SCHEDULED_TASK" + + gen = wrapped(fake_ctx, 7) + # First advance schedules the activity and yields the durabletask task. + yielded = next(gen) + assert yielded == "SCHEDULED_TASK" + fake_ctx.call_activity.assert_called_once_with("A", input=5) + assert seen["input"] == 7 + + # Feeding the activity result resumes the orchestrator to completion. + with pytest.raises(StopIteration) as stop: + gen.send(10) + assert stop.value.value == 20 + assert seen["activity_result"] == 10 + + +def test_wrap_preserves_orchestrator_name(): + def my_orchestrator(context): + return None + assert wrap_orchestrator(my_orchestrator).__name__ == "my_orchestrator" From 3c05264432c3782921a4bb3210ab1ad55ac15801 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 12:14:39 -0600 Subject: [PATCH 29/33] Add remaining orchestration surface --- azure-functions-durable/CHANGELOG.md | 5 +++ .../internal/compat/orchestration_context.py | 45 +++++++++++++++++++ .../test_orchestration_context_compat.py | 38 ++++++++++++++++ 3 files changed, 88 insertions(+) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index f5856673..febfe6df 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -31,6 +31,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Two-argument (durabletask-native) orchestrators continue to work unchanged. `DurableOrchestrationContext.call_http` raises `NotImplementedError` pending a durabletask durable-HTTP implementation. +- `DurableOrchestrationContext` also exposes `custom_status` (reflecting the + value set via `set_custom_status`) and `will_continue_as_new` (True once + `continue_as_new` has been called). `parent_instance_id`, `function_context`, + and `histories` raise `NotImplementedError` because durabletask does not + surface that information on the orchestration context. - Backwards-compatible, deprecated aliases on `DurableFunctionsClient` for the v1 `DurableOrchestrationClient` method names: `start_new`, `get_status`, diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py index 9cfb7a12..76c22720 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -27,6 +27,8 @@ class DurableOrchestrationContext: def __init__(self, ctx: OrchestrationContext, orchestration_input: Any = None): self._ctx = ctx self._input = orchestration_input + self._custom_status: Any = None + self._will_continue_as_new = False # -- input --------------------------------------------------------------- def get_input(self) -> Any: @@ -49,6 +51,47 @@ def current_utc_datetime(self) -> datetime: """Get the replay-safe current UTC date/time.""" return self._ctx.current_utc_datetime + @property + def custom_status(self) -> Any: + """Get the custom status set during this execution (or ``None``).""" + return self._custom_status + + @property + def will_continue_as_new(self) -> bool: + """Whether :meth:`continue_as_new` has been called in this execution.""" + return self._will_continue_as_new + + @property + def parent_instance_id(self) -> str: + """Get the ID of the parent orchestration. + + Not available: durabletask does not currently surface the parent + instance ID on the orchestration context. + """ + raise NotImplementedError( + "parent_instance_id is not currently exposed by durabletask.") + + @property + def function_context(self) -> Any: + """Get the Azure Functions-level context. + + Not available: durabletask does not provide the v1 ``FunctionContext`` + binding metadata. + """ + raise NotImplementedError( + "function_context is not available in this SDK.") + + @property + def histories(self) -> Any: + """Get the running history of scheduled tasks. + + Not available: durabletask manages orchestration history internally and + does not expose it on the context. + """ + raise NotImplementedError( + "histories is not exposed by durabletask; use the client's " + "get_orchestration_history instead.") + # -- activities ---------------------------------------------------------- def call_activity(self, name: Callable[..., Any] | str, input_: Any = None) -> Task[Any]: """Schedule an activity function for execution.""" @@ -92,10 +135,12 @@ def wait_for_external_event(self, # -- control ------------------------------------------------------------- def continue_as_new(self, input_: Any) -> None: """Restart the orchestration with a new input.""" + self._will_continue_as_new = True self._ctx.continue_as_new(input_) def set_custom_status(self, status: Any) -> None: """Set the orchestration's custom status payload.""" + self._custom_status = status self._ctx.set_custom_status(status) # -- deterministic IDs --------------------------------------------------- diff --git a/tests/azure-functions-durable/test_orchestration_context_compat.py b/tests/azure-functions-durable/test_orchestration_context_compat.py index 119e6c29..fecd7f93 100644 --- a/tests/azure-functions-durable/test_orchestration_context_compat.py +++ b/tests/azure-functions-durable/test_orchestration_context_compat.py @@ -116,6 +116,44 @@ def test_call_http_raises_not_implemented(): adapter.call_http("GET", "http://example.com") +# --------------------------------------------------------------------------- +# Additional context members +# --------------------------------------------------------------------------- + +def test_custom_status_tracks_set_custom_status(): + adapter, fake = _adapter() + assert adapter.custom_status is None + adapter.set_custom_status({"progress": 50}) + assert adapter.custom_status == {"progress": 50} + fake.set_custom_status.assert_called_once_with({"progress": 50}) + + +def test_will_continue_as_new_tracks_continue_as_new(): + adapter, fake = _adapter() + assert adapter.will_continue_as_new is False + adapter.continue_as_new({"next": 1}) + assert adapter.will_continue_as_new is True + fake.continue_as_new.assert_called_once_with({"next": 1}) + + +def test_parent_instance_id_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + _ = adapter.parent_instance_id + + +def test_function_context_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + _ = adapter.function_context + + +def test_histories_raises_not_implemented(): + adapter, _ = _adapter() + with pytest.raises(NotImplementedError): + _ = adapter.histories + + # --------------------------------------------------------------------------- # Arity detection and wrapping # --------------------------------------------------------------------------- From 46570e9b4b47f5bd6c664625ed937322e075476b Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 12:25:04 -0600 Subject: [PATCH 30/33] Add missing type coersion, rich client, tests --- .../decorators/durable_app.py | 25 ++- .../internal/compat/orchestration_context.py | 26 ++- .../test_decorator_compat.py | 148 ++++++++++++++++++ .../test_serialization_compat.py | 71 +++++++++ 4 files changed, 260 insertions(+), 10 deletions(-) create mode 100644 tests/azure-functions-durable/test_decorator_compat.py create mode 100644 tests/azure-functions-durable/test_serialization_compat.py diff --git a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py index 8c2d68df..2b3473bb 100644 --- a/azure-functions-durable/azure/durable_functions/decorators/durable_app.py +++ b/azure-functions-durable/azure/durable_functions/decorators/durable_app.py @@ -12,6 +12,7 @@ from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \ DurableClient +from ..client import DurableFunctionsClient from ..worker import DurableFunctionsWorker from ..orchestrator import Orchestrator @@ -47,7 +48,8 @@ def __init__(self, def _configure_orchestrator_callable( self, - wrap: Callable[[Callable[..., Any]], FunctionBuilder] + wrap: Callable[[Callable[..., Any]], FunctionBuilder], + input_type: Optional[type] = None ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: """Obtain decorator to construct an Orchestrator class from a user-defined Function. @@ -55,6 +57,10 @@ def _configure_orchestrator_callable( ---------- wrap: Callable The next decorator to be applied. + input_type: Optional[type] + The expected type for orchestration input, forwarded from the + ``orchestration_trigger`` decorator so a v1-style + ``context.get_input()`` can decode the input to that type. Returns ------- @@ -65,6 +71,11 @@ def _configure_orchestrator_callable( def decorator(orchestrator_func: task.Orchestrator[Any, Any]) -> FunctionBuilder: # Construct an orchestrator based on the end-user code + if input_type is not None: + # Stash the decorator-declared input type so the runtime can + # feed it to a v1-style ``context.get_input()``. + orchestrator_func._df_input_type = input_type # type: ignore[attr-defined] # noqa: E501 + handle = Orchestrator.create(orchestrator_func) # invoke next decorator, with the Orchestrator as input @@ -167,7 +178,8 @@ def decorator(func: Callable[..., Any]) -> FunctionBuilder: return decorator def orchestration_trigger(self, context_name: str, - orchestration: Optional[str] = None + orchestration: Optional[str] = None, + input_type: Optional[type] = None ) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]: """Register an Orchestrator Function. @@ -178,8 +190,12 @@ def orchestration_trigger(self, context_name: str, orchestration: Optional[str] Name of Orchestrator Function. The value is None by default, in which case the name of the method is used. + input_type: Optional[type] + The expected type for the orchestration input. When set, a v1-style + ``context.get_input()`` decodes the input payload to this type. A + call-site ``expected_type`` argument on ``get_input`` takes + precedence over this value. """ - @self._configure_orchestrator_callable @self._build_function def wrap(fb: FunctionBuilder) -> FunctionBuilder: @@ -191,7 +207,7 @@ def decorator() -> FunctionBuilder: return decorator() - return wrap + return self._configure_orchestrator_callable(wrap, input_type=input_type) def activity_trigger(self, input_name: str, activity: Optional[str] = None @@ -271,6 +287,7 @@ def durable_client_input(self, @self._build_function def wrap(fb: FunctionBuilder) -> FunctionBuilder: def decorator() -> FunctionBuilder: + self._add_rich_client(fb, client_name, DurableFunctionsClient) fb.add_binding( binding=DurableClient(name=client_name, task_hub=task_hub, diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py index 76c22720..0c324206 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -10,6 +10,7 @@ from durabletask.entities import EntityInstanceId from durabletask.task import OrchestrationContext, RetryPolicy, Task +from ..serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .token_source import TokenSource @@ -24,16 +25,28 @@ class DurableOrchestrationContext: context directly instead. """ - def __init__(self, ctx: OrchestrationContext, orchestration_input: Any = None): + def __init__(self, + ctx: OrchestrationContext, + orchestration_input: Any = None, + input_type: Optional[type] = None): self._ctx = ctx self._input = orchestration_input + self._input_type = input_type self._custom_status: Any = None self._will_continue_as_new = False # -- input --------------------------------------------------------------- - def get_input(self) -> Any: - """Get the orchestration input.""" - return self._input + def get_input(self, expected_type: Optional[type] = None) -> Any: + """Get the orchestration input. + + When an ``expected_type`` (or the ``input_type`` declared on the + ``orchestration_trigger`` decorator) is available, the already-decoded + input is coerced to that type; otherwise the raw value is returned. + """ + resolved_type = expected_type or self._input_type + if resolved_type is None: + return self._input + return DEFAULT_FUNCTIONS_DATA_CONVERTER.coerce(self._input, resolved_type) # -- properties ---------------------------------------------------------- @property @@ -227,11 +240,12 @@ def wrap_orchestrator(fn: Callable[..., Any]) -> Callable[..., Any]: if accepts_two_positional_args(fn): return fn + input_type = getattr(fn, "_df_input_type", None) name = getattr(fn, "__name__", "orchestrator") if inspect.isgeneratorfunction(fn): def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any: - adapter = DurableOrchestrationContext(context, _input) + adapter = DurableOrchestrationContext(context, _input, input_type) generator = cast("Generator[Any, Any, Any]", fn(adapter)) result: Any = yield from generator return result @@ -239,7 +253,7 @@ def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any return _generator_wrapper def _wrapper(context: OrchestrationContext, _input: Any = None) -> Any: - adapter = DurableOrchestrationContext(context, _input) + adapter = DurableOrchestrationContext(context, _input, input_type) return fn(adapter) _wrapper.__name__ = name return _wrapper diff --git a/tests/azure-functions-durable/test_decorator_compat.py b/tests/azure-functions-durable/test_decorator_compat.py new file mode 100644 index 00000000..2ffe2ed9 --- /dev/null +++ b/tests/azure-functions-durable/test_decorator_compat.py @@ -0,0 +1,148 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import json + +import azure.durable_functions as df +from azure.durable_functions import DurableFunctionsClient +from azure.durable_functions.constants import ( + ACTIVITY_TRIGGER, + DURABLE_CLIENT, + ENTITY_TRIGGER, + ORCHESTRATION_TRIGGER, +) + + +_CLIENT_CONFIG = json.dumps({ + "taskHubName": "TestHub", + "requiredQueryStringParameters": "code=xyz", + "baseUrl": "http://localhost:7071/runtime/webhooks/durabletask", + "rpcBaseUrl": "http://localhost:8080/", +}) + + +def _trigger(fb): + return fb._function.get_trigger() + + +# --------------------------------------------------------------------------- +# orchestration_trigger +# --------------------------------------------------------------------------- + +def test_orchestration_trigger_v1_signature(): + app = df.DFApp() + + def my_orchestrator(context): + return 1 + + fb = app.orchestration_trigger( + context_name="context", orchestration="MyOrchestrator")(my_orchestrator) + trigger = _trigger(fb) + assert trigger.get_binding_name() == ORCHESTRATION_TRIGGER + assert trigger.name == "context" + assert trigger.orchestration == "MyOrchestrator" + + +def test_orchestration_trigger_accepts_input_type(): + app = df.DFApp() + + def my_orchestrator(context): + return 1 + + # v1 parity: the input_type keyword must be accepted and stashed. + fb = app.orchestration_trigger( + context_name="context", input_type=dict)(my_orchestrator) + assert fb is not None + assert my_orchestrator._df_input_type is dict + + +# --------------------------------------------------------------------------- +# activity_trigger +# --------------------------------------------------------------------------- + +def test_activity_trigger_v1_signature(): + app = df.DFApp() + + def my_activity(myinput): + return myinput + + fb = app.activity_trigger( + input_name="myinput", activity="MyActivity")(my_activity) + trigger = _trigger(fb) + assert trigger.get_binding_name() == ACTIVITY_TRIGGER + assert trigger.name == "myinput" + assert trigger.activity == "MyActivity" + + +# --------------------------------------------------------------------------- +# entity_trigger +# --------------------------------------------------------------------------- + +def test_entity_trigger_v1_signature(): + app = df.DFApp() + + def my_entity(context): + return None + + fb = app.entity_trigger( + context_name="context", entity_name="MyEntity")(my_entity) + trigger = _trigger(fb) + assert trigger.get_binding_name() == ENTITY_TRIGGER + assert trigger.name == "context" + assert trigger.entity_name == "MyEntity" + + +# --------------------------------------------------------------------------- +# durable_client_input +# --------------------------------------------------------------------------- + +def test_durable_client_input_v1_signature_registers_binding(): + app = df.DFApp() + + async def starter(client): + return None + + fb = app.durable_client_input( + client_name="client", task_hub="hub", connection_name="conn")(starter) + bindings = fb._function.get_bindings() + client_bindings = [b for b in bindings if b.get_binding_name() == DURABLE_CLIENT] + assert len(client_bindings) == 1 + binding = client_bindings[0] + assert binding.name == "client" + assert binding.task_hub == "hub" + assert binding.connection_name == "conn" + + +async def test_durable_client_input_injects_rich_client(): + app = df.DFApp() + received = {} + + async def starter(client): + received["client"] = client + + fb = app.durable_client_input(client_name="client")(starter) + # _add_rich_client replaces the user function with middleware that builds + # a DurableFunctionsClient from the binding's JSON string. + middleware = fb._function._func + await middleware(client=_CLIENT_CONFIG) + + client = received["client"] + assert isinstance(client, DurableFunctionsClient) + try: + assert client.taskHubName == "TestHub" + finally: + await client.close() + + +# --------------------------------------------------------------------------- +# All decorators register a function builder +# --------------------------------------------------------------------------- + +def test_decorators_register_function_builders(): + app = df.DFApp() + + def orch(context): + return 1 + + app.orchestration_trigger(context_name="context")(orch) + assert len(app._function_builders) == 1 diff --git a/tests/azure-functions-durable/test_serialization_compat.py b/tests/azure-functions-durable/test_serialization_compat.py new file mode 100644 index 00000000..39c98dcc --- /dev/null +++ b/tests/azure-functions-durable/test_serialization_compat.py @@ -0,0 +1,71 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +import pytest + +from azure.durable_functions.internal.serialization import ( + DEFAULT_FUNCTIONS_DATA_CONVERTER, +) + + +class Point: + """Sample custom type using the v1 to_json / from_json convention.""" + + def __init__(self, x: int, y: int): + self.x = x + self.y = y + + def to_json(self): + return {"x": self.x, "y": self.y} + + @classmethod + def from_json(cls, data): + return cls(data["x"], data["y"]) + + def __eq__(self, other): + return isinstance(other, Point) and self.x == other.x and self.y == other.y + + +def test_custom_object_round_trips(): + point = Point(3, 4) + serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(point) + assert isinstance(serialized, str) + + restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized) + assert isinstance(restored, Point) + assert restored == point + + +def test_nested_custom_object_round_trips(): + payload = {"points": [Point(1, 1), Point(2, 2)], "label": "path"} + serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(payload) + restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized) + assert restored["label"] == "path" + assert restored["points"] == [Point(1, 1), Point(2, 2)] + + +@pytest.mark.parametrize("value", [ + {"a": 1, "b": [1, 2, 3]}, + [1, 2, 3], + "hello", + 42, + 3.14, + True, +]) +def test_builtin_values_round_trip(value): + serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(value) + assert isinstance(serialized, str) + restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized) + assert restored == value + + +def test_none_round_trips(): + assert DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(None) is None + assert DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(None) is None + + +def test_coerce_plain_dict_to_type(): + # get_input(expected_type=...) relies on the converter coercing a plain + # (already-deserialized) dict into the declared type. + coerced = DEFAULT_FUNCTIONS_DATA_CONVERTER.coerce({"x": 5, "y": 6}, Point) + assert coerced == Point(5, 6) From af18afed481c6594f78bedc6d570a20679900474 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 13:13:13 -0600 Subject: [PATCH 31/33] Fix gaps found via integration tests --- CHANGELOG.md | 10 +++++ azure-functions-durable/CHANGELOG.md | 20 +++++++++ .../azure/durable_functions/client.py | 11 +++++ .../compat/durable_orchestration_status.py | 41 +++++++++++++++---- .../internal/compat/orchestration_context.py | 13 ++++-- durabletask/task.py | 5 +++ durabletask/worker.py | 5 +++ .../test_client_compat.py | 3 ++ 8 files changed, 98 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 46df1f48..15346b80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased +ADDED + +- Added a `result` property to `Task` as a convenience alias for `get_result()`. + +FIXED + +- `OrchestrationContext.create_timer` now accepts timezone-aware `datetime` + values, normalizing them to UTC instead of raising when compared against the + orchestration's internal clock. + ## v1.7.0 ADDED diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index febfe6df..54510821 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -7,8 +7,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +### Fixed + +- `durable_client_input` now injects a rich `DurableFunctionsClient` into the + decorated function's client parameter (the binding's JSON string is converted + to a client object). Previously the client parameter received the raw string. +- `DurableFunctionsClient` now applies the host-provided + `maxGrpcMessageSizeInBytes` to the gRPC channel's send/receive message limits + (when provided), allowing large orchestration payloads to be retrieved. When + the host does not supply a value, the gRPC library defaults are left in place. +- `DurableOrchestrationContext.current_utc_datetime` is now timezone-aware + (UTC), matching v1, so comparisons against timezone-aware datetimes (e.g. a + parsed scheduled-start time) no longer raise. +- `DurableOrchestrationStatus.to_json()` now emits orchestration payloads + (`output`, `input`, `customStatus`) as their raw JSON representation instead + of reconstructed Python objects, so the result is always JSON-serializable + even when payloads are custom types. + ### Added +- The `orchestration_trigger` decorator now accepts an `input_type` argument + (v1 parity). When set, a v1-style `context.get_input()` decodes the input to + that type; a call-site `expected_type` on `get_input` takes precedence. - One-argument (Azure Functions / v1-style) entity functions (``def entity(context):``) are now supported. The worker detects the entity's shape and, for single-argument functions, delivers a functional diff --git a/azure-functions-durable/azure/durable_functions/client.py b/azure-functions-durable/azure/durable_functions/client.py index fda5df28..d4de1ab6 100644 --- a/azure-functions-durable/azure/durable_functions/client.py +++ b/azure-functions-durable/azure/durable_functions/client.py @@ -15,6 +15,7 @@ OrchestrationStatus, ) from durabletask.entities import EntityInstanceId +from durabletask.grpc_options import GrpcChannelOptions from .internal.azurefunctions_grpc_interceptor import AzureFunctionsAsyncDefaultClientInterceptorImpl from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER from .http import HttpManagementPayload @@ -58,6 +59,15 @@ def __init__(self, client_as_string: str): interceptors = [AzureFunctionsAsyncDefaultClientInterceptorImpl(self.taskHubName, self.requiredQueryStringParameters)] + # Only override the gRPC message size limits when the host explicitly + # provides a value. When unset (0), we leave the gRPC library defaults + # in place rather than applying a large default of our own. + channel_options: GrpcChannelOptions | None = None + if self.maxGrpcMessageSizeInBytes > 0: + channel_options = GrpcChannelOptions( + max_receive_message_length=self.maxGrpcMessageSizeInBytes, + max_send_message_length=self.maxGrpcMessageSizeInBytes) + # We pass in None for the metadata so we don't construct an additional interceptor in the parent class # Since the parent class doesn't use anything metadata for anything else, we can set it as None super().__init__( @@ -65,6 +75,7 @@ def __init__(self, client_as_string: str): secure_channel=False, metadata=None, interceptors=interceptors, + channel_options=channel_options, data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER) def _parse_client_configuration(self, client_as_string: str) -> None: diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py index 2db4f94f..d389c474 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +import json from datetime import datetime from typing import Any, Optional @@ -94,7 +95,13 @@ def history(self) -> Optional[list[Any]]: return None def to_json(self) -> dict[str, Any]: - """Convert this status into a v1-compatible JSON dictionary.""" + """Convert this status into a v1-compatible JSON dictionary. + + Payload fields (``output``, ``input``, ``customStatus``) are emitted as + their raw JSON representation rather than the reconstructed Python + objects, so the result is always JSON-serializable even when the + orchestration payloads are custom types. + """ result: dict[str, Any] = {} if self.name is not None: result["name"] = self.name @@ -104,12 +111,32 @@ def to_json(self) -> dict[str, Any]: result["createdTime"] = self.created_time.isoformat() if self.last_updated_time is not None: result["lastUpdatedTime"] = self.last_updated_time.isoformat() - if self.output is not None: - result["output"] = self.output - if self.input_ is not None: - result["input"] = self.input_ + output = self._raw_payload( + self._state.serialized_output if self._state is not None else None) + if output is not None: + result["output"] = output + input_ = self._raw_payload( + self._state.serialized_input if self._state is not None else None) + if input_ is not None: + result["input"] = input_ if self.runtime_status is not None: result["runtimeStatus"] = self.runtime_status.name - if self.custom_status is not None: - result["customStatus"] = self.custom_status + custom_status = self._raw_payload( + self._state.serialized_custom_status if self._state is not None else None) + if custom_status is not None: + result["customStatus"] = custom_status return result + + @staticmethod + def _raw_payload(serialized: Optional[str]) -> Any: + """Parse a serialized payload as plain JSON without reconstructing types. + + Returns the parsed JSON value (which is always JSON-serializable), or the + original string if it is not valid JSON, or ``None`` when absent. + """ + if serialized is None: + return None + try: + return json.loads(serialized) + except (TypeError, ValueError): + return serialized diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py index 0c324206..0e08f834 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -2,7 +2,7 @@ # Licensed under the MIT License. import inspect -from datetime import datetime +from datetime import datetime, timezone from typing import Any, Callable, Generator, Optional, cast from uuid import UUID @@ -61,8 +61,15 @@ def is_replaying(self) -> bool: @property def current_utc_datetime(self) -> datetime: - """Get the replay-safe current UTC date/time.""" - return self._ctx.current_utc_datetime + """Get the replay-safe current UTC date/time. + + Returned as a timezone-aware (UTC) datetime for v1 compatibility; + durabletask exposes a naive UTC datetime. + """ + value = self._ctx.current_utc_datetime + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value @property def custom_status(self) -> Any: diff --git a/durabletask/task.py b/durabletask/task.py index 2085fd9c..564506f4 100644 --- a/durabletask/task.py +++ b/durabletask/task.py @@ -503,6 +503,11 @@ def is_failed(self) -> bool: """Returns True if the task has failed, False otherwise.""" return self._exception is not None + @property + def result(self) -> T: + """Returns the result of the task (alias for :meth:`get_result`).""" + return self.get_result() + def get_result(self) -> T: """Returns the result of the task.""" if not self._is_complete: diff --git a/durabletask/worker.py b/durabletask/worker.py index 29824632..10da62cf 100644 --- a/durabletask/worker.py +++ b/durabletask/worker.py @@ -1668,6 +1668,11 @@ def create_timer_internal( else: final_fire_at = fire_at + # Normalize timezone-aware datetimes to naive UTC so they can be safely + # compared against and combined with the orchestration's naive UTC clock. + if final_fire_at.tzinfo is not None: + final_fire_at = final_fire_at.astimezone(timezone.utc).replace(tzinfo=None) + next_fire_at: datetime = final_fire_at if ( diff --git a/tests/azure-functions-durable/test_client_compat.py b/tests/azure-functions-durable/test_client_compat.py index fcdb17c2..263ab872 100644 --- a/tests/azure-functions-durable/test_client_compat.py +++ b/tests/azure-functions-durable/test_client_compat.py @@ -399,6 +399,9 @@ def _fake_state(): created_at=datetime(2026, 1, 1, tzinfo=timezone.utc), last_updated_at=datetime(2026, 1, 2, tzinfo=timezone.utc), runtime_status=OrchestrationStatus.RUNNING, + serialized_input='{"in": 1}', + serialized_output='{"out": 2}', + serialized_custom_status='"cs"', get_input=lambda: {"in": 1}, get_output=lambda: {"out": 2}, get_custom_status=lambda: "cs", From 013f6740521db2a156cc9e5b11391733d50353f7 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 15:21:55 -0600 Subject: [PATCH 32/33] Fix old-name interceptor lookup --- .../internal/azurefunctions_grpc_interceptor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py index f4011322..35373b20 100644 --- a/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py +++ b/azure-functions-durable/azure/durable_functions/internal/azurefunctions_grpc_interceptor.py @@ -13,7 +13,7 @@ def _build_metadata(taskhub_name: str) -> list[tuple[str, str]]: """Build the gRPC metadata headers sent on every Durable Functions call.""" try: # Get the version of the azurefunctions package - sdk_version = version('durabletask-azurefunctions') + sdk_version = version('azure-functions-durable') except Exception: # Fallback if version cannot be determined sdk_version = "unknown" From 60b61956e8d3d2ea8181f3b38196c17d8466376d Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 2 Jul 2026 15:41:21 -0600 Subject: [PATCH 33/33] More compat layer stuff --- azure-functions-durable/CHANGELOG.md | 18 ++++++- .../http/http_management_payload.py | 49 +++++++------------ .../compat/durable_orchestration_status.py | 42 +++++++++++++++- .../internal/compat/orchestration_context.py | 5 ++ .../internal/compat/purge_history_result.py | 7 +++ .../internal/compat/retry_options.py | 7 +++ 6 files changed, 93 insertions(+), 35 deletions(-) diff --git a/azure-functions-durable/CHANGELOG.md b/azure-functions-durable/CHANGELOG.md index 54510821..84f9aed3 100644 --- a/azure-functions-durable/CHANGELOG.md +++ b/azure-functions-durable/CHANGELOG.md @@ -23,6 +23,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (`output`, `input`, `customStatus`) as their raw JSON representation instead of reconstructed Python objects, so the result is always JSON-serializable even when payloads are custom types. +- Restored v1 members that were missing on the compatibility types, avoiding + `AttributeError`/`TypeError` for existing code that used them: + - `create_http_management_payload(...)` now returns a `dict`-based + `HttpManagementPayload`, so `json.dumps(payload)` works directly again. + - `RetryOptions.to_json()` returns the v1 + `firstRetryIntervalInMilliseconds`/`maxNumberOfAttempts` dictionary, and the + `first_retry_interval_in_milliseconds` / `max_number_of_attempts` getters + remain available. + - `DurableOrchestrationStatus.from_json(...)` reconstructs a status from its + `to_json()` representation (or the equivalent v1 JSON schema). + - `PurgeHistoryResult.from_json(...)` reconstructs a result from its v1 JSON + representation. + - `DurableOrchestrationContext.version` returns the orchestration instance + version (or `None`). ### Added @@ -103,11 +117,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `create_http_management_payload` now accepts either the durabletask `(request, instance_id)` signature or the v1 `(instance_id)` signature for backwards compatibility. -- `HttpManagementPayload` now supports mapping-style access +- `HttpManagementPayload` now subclasses `dict`, so it is directly + JSON-serializable via `json.dumps(payload)` and supports mapping-style access (`payload["statusQueryGetUri"]`, iteration, `in`, `keys()`/`items()`/`values()`) so v1 code that treated the payload as a `dict` keeps working. - ## v0.1.0 - Initial implementation diff --git a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py index 4c1b634b..8f33ce91 100644 --- a/azure-functions-durable/azure/durable_functions/http/http_management_payload.py +++ b/azure-functions-durable/azure/durable_functions/http/http_management_payload.py @@ -2,18 +2,20 @@ # Licensed under the MIT License. import json -from collections.abc import Iterator +from typing import Any -class HttpManagementPayload: +class HttpManagementPayload(dict[str, str]): """A class representing the HTTP management payload for a Durable Function orchestration instance. Contains URLs for managing the instance, such as querying status, sending events, terminating, restarting, etc. - Supports mapping-style access (``payload["statusQueryGetUri"]``, iteration, - ``in``, ``.keys()``/``.items()``/``.values()``) for backwards compatibility - with the v1 API, which returned a plain ``dict``. + Subclasses ``dict`` for backwards compatibility with the v1 API, which + returned a plain ``dict``. As a result the payload supports mapping-style + access (``payload["statusQueryGetUri"]``, iteration, ``in``, + ``.keys()``/``.items()``/``.values()``) and is directly JSON-serializable + via ``json.dumps(payload)``. """ def __init__(self, instance_id: str, instance_status_url: str, required_query_string_parameters: str): @@ -24,7 +26,7 @@ def __init__(self, instance_id: str, instance_status_url: str, required_query_st instance_status_url (str): The base URL for the instance status. required_query_string_parameters (str): The required URL parameters provided by the Durable extension. """ - self.urls = { + super().__init__({ 'id': instance_id, 'purgeHistoryDeleteUri': instance_status_url + "?" + required_query_string_parameters, 'restartPostUri': instance_status_url + "/restart?" + required_query_string_parameters, @@ -33,31 +35,16 @@ def __init__(self, instance_id: str, instance_status_url: str, required_query_st 'terminatePostUri': instance_status_url + "/terminate?reason={text}&" + required_query_string_parameters, 'resumePostUri': instance_status_url + "/resume?reason={text}&" + required_query_string_parameters, 'suspendPostUri': instance_status_url + "/suspend?reason={text}&" + required_query_string_parameters - } + }) - def __str__(self): - return json.dumps(self.urls) + def __str__(self) -> str: + return json.dumps(self) - def __getitem__(self, key: str) -> str: - return self.urls[key] + @property + def urls(self) -> dict[str, Any]: + """Return the management URLs as a plain ``dict`` (v1 compatibility).""" + return dict(self) - def __iter__(self) -> Iterator[str]: - return iter(self.urls) - - def __len__(self) -> int: - return len(self.urls) - - def __contains__(self, key: object) -> bool: - return key in self.urls - - def keys(self): - """Return the management URL keys.""" - return self.urls.keys() - - def items(self): - """Return the management URL (key, value) pairs.""" - return self.urls.items() - - def values(self): - """Return the management URL values.""" - return self.urls.values() + def to_json(self) -> dict[str, Any]: + """Return the management URLs as a plain ``dict``.""" + return dict(self) diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py index d389c474..a1e7f9af 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/durable_orchestration_status.py @@ -3,13 +3,14 @@ import json from datetime import datetime -from typing import Any, Optional +from typing import Any, Optional, cast -from durabletask.client import OrchestrationState +from durabletask.client import OrchestrationState, OrchestrationStatus from .orchestration_runtime_status import ( OrchestrationRuntimeStatus, from_durabletask_status, + to_durabletask_status, ) @@ -35,6 +36,43 @@ def from_orchestration_state( """Wrap a durabletask ``OrchestrationState`` (or ``None``).""" return cls(state) + @classmethod + def from_json(cls, json_obj: Any) -> "DurableOrchestrationStatus": + """Reconstruct a status from its v1 JSON representation. + + Accepts the dictionary produced by :meth:`to_json` (or the equivalent v1 + schema); a JSON string is parsed first. The wrapped + ``OrchestrationState`` is rebuilt so the resulting object exposes the + same attribute surface as one returned by the client. + """ + if isinstance(json_obj, str): + json_obj = json.loads(json_obj) + data = dict(json_obj) + + runtime_status = data.get("runtimeStatus") + dt_status = ( + to_durabletask_status(OrchestrationRuntimeStatus(runtime_status)) + if runtime_status is not None else None) + + def _parse_datetime(value: Any) -> Any: + return datetime.fromisoformat(value) if isinstance(value, str) else value + + def _reserialize(value: Any) -> Optional[str]: + return None if value is None else json.dumps(value) + + state = OrchestrationState( + instance_id=cast(str, data.get("instanceId")), + name=cast(str, data.get("name")), + runtime_status=cast(OrchestrationStatus, dt_status), + created_at=cast(datetime, _parse_datetime(data.get("createdTime"))), + last_updated_at=cast(datetime, _parse_datetime(data.get("lastUpdatedTime"))), + serialized_input=_reserialize(data.get("input")), + serialized_output=_reserialize(data.get("output")), + serialized_custom_status=_reserialize(data.get("customStatus")), + failure_details=None, + ) + return cls(state) + def __bool__(self) -> bool: return self._state is not None diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py index 0e08f834..294fe755 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py @@ -81,6 +81,11 @@ def will_continue_as_new(self) -> bool: """Whether :meth:`continue_as_new` has been called in this execution.""" return self._will_continue_as_new + @property + def version(self) -> Optional[str]: + """Get the version assigned to the orchestration instance (or ``None``).""" + return self._ctx.version + @property def parent_instance_id(self) -> str: """Get the ID of the parent orchestration. diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py b/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py index e9d300ff..adf3dd06 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/purge_history_result.py @@ -1,6 +1,8 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +from typing import Any + from durabletask.client import PurgeInstancesResult @@ -21,6 +23,11 @@ def from_purge_result(cls, result: PurgeInstancesResult) -> "PurgeHistoryResult" """Wrap a durabletask ``PurgeInstancesResult``.""" return cls(result.deleted_instance_count) + @classmethod + def from_json(cls, json_obj: "dict[str, Any]") -> "PurgeHistoryResult": + """Reconstruct a result from its v1 JSON representation.""" + return cls(instances_deleted=json_obj["instancesDeleted"]) + @property def instances_deleted(self) -> int: """Get the number of deleted instances.""" diff --git a/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py b/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py index 1943007b..0a615513 100644 --- a/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py +++ b/azure-functions-durable/azure/durable_functions/internal/compat/retry_options.py @@ -44,3 +44,10 @@ def __init__( def first_retry_interval_in_milliseconds(self) -> int: """Get the first retry interval, in milliseconds.""" return int(self.first_retry_interval / timedelta(milliseconds=1)) + + def to_json(self) -> dict[str, int]: + """Return the v1 JSON representation of these retry options.""" + return { + "firstRetryIntervalInMilliseconds": self.first_retry_interval_in_milliseconds, + "maxNumberOfAttempts": self.max_number_of_attempts, + }