From 7ff1525d9c14001176b26a4e56af584b9391d920 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 6 Nov 2025 10:01:56 -0700 Subject: [PATCH 01/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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/23] 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]