Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions azure/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
CosmosDBChangeFeedMode, PromptArgument)
from .decorators.mcp import mcp_content
from ._durable_functions import OrchestrationContext, EntityContext
from .durable_functions import register_durable_converters
from .decorators.function_app import (FunctionRegister, TriggerApi,
BindingApi, SettingsApi)
from .extension import (ExtensionMeta, FunctionExtensionException,
Expand Down Expand Up @@ -48,6 +49,13 @@
from . import connectors # NoQA


# Register Durable Functions converters lazily on the first binding lookup.
# Registering at import time would trigger importing the Durable Functions
# SDK (which imports azure.functions at its top level) while azure.functions
# is still initializing -- a re-entrant import.
get_binding_registry().register_deferred(register_durable_converters)


__all__ = (
# Functions
'get_binding_registry',
Expand Down
39 changes: 39 additions & 0 deletions azure/functions/decorators/durable_functions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.
import logging

_logger = logging.getLogger('azure.functions.DurableFunctions')

df = None


def get_durable_package():
"""Determines which Durable SDK is being used.

If the `azure-functions-durable` package is installed, we
log a warning that this legacy package
is deprecated.

If both the legacy and current packages are installed,
we log a warning and prefer the current package.

If neither package is installed, we return None.
"""
global df
if df:
return df

try:
import azure.durable_functions as durable_functions # noqa
except ImportError:
_logger.debug("`azure.durable_functions` package not found.")
return None

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In which case this will return none? Won't a durable app always has the SDK installed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question — the None path is reachable and intentional in two cases:

  1. Non-durable apps (the common case). register_durable_converters() runs at import of azure.functions for every app. The majority of apps don't have azure-functions-durable installed, so get_durable_package() returns None and registration is a deliberate no-op.

  2. A user references a durable decorator without the SDK installed. The durable decorators (orchestration_trigger, entity_trigger, activity_trigger on TriggerApi; durable_client_input on BindingApi) are defined unconditionally in the base azure-functions library and inherited by every FunctionApp/Blueprint. So a user can reference @app.orchestration_trigger even without the SDK present. In that case _get_durable_blueprint() gets None and raises the explicit "please install azure-functions-durable" error, instead of surfacing a confusing ImportError/AttributeError.

So a correctly-configured durable app will always have the SDK, but None is the right guard for both the non-durable-app import path and the misconfigured-durable-app path.


if hasattr(durable_functions, 'version') and durable_functions.version.startswith("2."):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current SDK does not expose  azure.durable_functions.version. This will always log v1 package is used right?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFD V2 will expose this property

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct — with the current published SDK there's no version attribute, so it takes the else branch and logs v1.x, which is the intended behavior today. The version-based check is forward-compat: once the v2.x (Durable Task) SDK ships exposing version = "2.x", the same code will detect and log v2.x and register the new converters. It's a debug-only log, so no functional impact either way.

_logger.debug("Using `azure.durable_functions` v2.x package.")
else:
_logger.debug("Using `azure.durable_functions` v1.x package.")

df = durable_functions

return df
9 changes: 6 additions & 3 deletions azure/functions/decorators/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
DaprBindingTrigger, DaprInvokeOutput, DaprPublishOutput, \
DaprSecretInput, DaprServiceInvocationTrigger, DaprStateInput, \
DaprStateOutput, DaprTopicTrigger
from azure.functions.decorators.durable_functions import get_durable_package
from azure.functions.decorators.eventgrid import EventGridTrigger, \
EventGridOutput
from azure.functions.decorators.eventhub import EventHubTrigger, EventHubOutput
Expand Down Expand Up @@ -352,11 +353,13 @@ def _get_durable_blueprint(self):
"""Attempt to import the Durable Functions SDK from which DF
decorators are implemented.
"""
try:
import azure.durable_functions as df
logger = logging.getLogger('azure.functions.DurableFunctions')
logger.debug("Getting Durable Functions blueprint.")
df = get_durable_package()
if df:
df_bp = df.Blueprint()
return df_bp
except ImportError:
else:
error_message = \
"Attempted to use a Durable Functions decorator, " \
"but the `azure-functions-durable` SDK package could not be " \
Expand Down
242 changes: 223 additions & 19 deletions azure/functions/durable_functions.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

import typing
import json
import logging
import typing

from azure.functions import _durable_functions
from azure.functions.decorators.durable_functions import get_durable_package
from . import meta

_logger = logging.getLogger('azure.functions.DurableFunctions')

# Durable Function Orchestration Trigger
class OrchestrationTriggerConverter(meta.InConverter,
meta.OutConverter,
binding='orchestrationTrigger',
trigger=True):

# ---------------- Legacy Durable Functions Converters ---------------- #
# Legacy Durable Function Orchestration Trigger
class LegacyOrchestrationTriggerConverter(meta.InConverter,
meta.OutConverter,
binding=None,
trigger=True):
@classmethod
def check_input_type_annotation(cls, pytype):
return issubclass(pytype, _durable_functions.OrchestrationContext)
Expand All @@ -39,10 +44,11 @@ def has_implicit_output(cls) -> bool:
return True


class EnitityTriggerConverter(meta.InConverter,
meta.OutConverter,
binding='entityTrigger',
trigger=True):
# Legacy Durable Function Entity Trigger
class LegacyEnitityTriggerConverter(meta.InConverter,
meta.OutConverter,
binding=None,
trigger=True):
@classmethod
def check_input_type_annotation(cls, pytype):
return issubclass(pytype, _durable_functions.EntityContext)
Expand All @@ -69,11 +75,11 @@ def has_implicit_output(cls) -> bool:
return True


# Durable Function Activity Trigger
class ActivityTriggerConverter(meta.InConverter,
meta.OutConverter,
binding='activityTrigger',
trigger=True):
# Legacy Durable Function Activity Trigger
class LegacyActivityTriggerConverter(meta.InConverter,
meta.OutConverter,
binding=None,
trigger=True):
@classmethod
def check_input_type_annotation(cls, pytype):
# Activity Trigger's arguments should accept any types
Expand Down Expand Up @@ -135,10 +141,10 @@ def has_implicit_output(cls) -> bool:
return True


# Durable Functions Durable Client Bindings
class DurableClientConverter(meta.InConverter,
meta.OutConverter,
binding='durableClient'):
# Legacy Durable Functions Durable Client Bindings
class LegacyDurableClientConverter(meta.InConverter,
meta.OutConverter,
binding=None):
@classmethod
def has_implicit_output(cls) -> bool:
return False
Expand Down Expand Up @@ -199,3 +205,201 @@ def decode(cls, data: meta.Datum, *, trigger_metadata) -> typing.Any:
)

return result


# ---------------- Durable Task Durable Functions Converters ---------------- #
# Durable Function Orchestration Trigger
class OrchestrationTriggerConverter(meta.InConverter,
meta.OutConverter,
binding=None,
trigger=True):
@classmethod
def check_input_type_annotation(cls, pytype):
return issubclass(pytype, _durable_functions.OrchestrationContext)

@classmethod
def check_output_type_annotation(cls, pytype):
# Implicit output should accept any return type
return True

@classmethod
def decode(cls,
data: meta.Datum, *,
trigger_metadata) -> _durable_functions.OrchestrationContext:
return _durable_functions.OrchestrationContext(data.value)

@classmethod
def encode(cls, obj: typing.Any, *,
expected_type: typing.Optional[type]) -> meta.Datum:
# Durable function context should be a string
return meta.Datum(type='string', value=obj)

@classmethod
def has_implicit_output(cls) -> bool:
return True


# Durable Function Entity Trigger
class EnitityTriggerConverter(meta.InConverter,
meta.OutConverter,
binding=None,
trigger=True):
@classmethod
def check_input_type_annotation(cls, pytype):
return issubclass(pytype, _durable_functions.EntityContext)

@classmethod
def check_output_type_annotation(cls, pytype):
# Implicit output should accept any return type
return True

@classmethod
def decode(cls,
data: meta.Datum, *,
trigger_metadata) -> _durable_functions.EntityContext:
return _durable_functions.EntityContext(data.value)

@classmethod
def encode(cls, obj: typing.Any, *,
expected_type: typing.Optional[type]) -> meta.Datum:
# Durable function context should be a string
return meta.Datum(type='string', value=obj)

@classmethod
def has_implicit_output(cls) -> bool:
return True


# Durable Function Activity Trigger
class ActivityTriggerConverter(meta.InConverter,
meta.OutConverter,
binding=None,
trigger=True):
@classmethod
def check_input_type_annotation(cls, pytype):
# Activity Trigger's arguments should accept any types
return True

@classmethod
def check_output_type_annotation(cls, pytype):
# The activity trigger should accept any JSON serializable types
return True

@classmethod
def decode(cls,
data: meta.Datum, *,
trigger_metadata) -> typing.Any:
data_type = data.type

# Durable functions extension always returns a string of json
# See durable functions library's call_activity_task docs
if data_type in ['string', 'json']:
try:
callback = _durable_functions._deserialize_custom_object
result = json.loads(data.value, object_hook=callback)
except json.JSONDecodeError:
# String failover if the content is not json serializable
result = data.value
except Exception as e:
raise ValueError(
'activity trigger input must be a string or a '
f'valid json serializable ({data.value})') from e
else:
raise NotImplementedError(
f'unsupported activity trigger payload type: {data_type}')

return result

@classmethod
def encode(cls, obj: typing.Any, *,
expected_type: typing.Optional[type]) -> meta.Datum:
try:
callback = _durable_functions._serialize_custom_object
result = json.dumps(obj, default=callback)
except TypeError as e:
raise ValueError(
f'activity trigger output must be json serializable ({obj})') from e

return meta.Datum(type='json', value=result)

@classmethod
def has_implicit_output(cls) -> bool:
return True


# Durable Functions Durable Client Bindings
class DurableClientConverter(meta.InConverter,
meta.OutConverter,
binding=None):
@classmethod
def has_implicit_output(cls) -> bool:
return False

@classmethod
def has_trigger_support(cls) -> bool:
return False

@classmethod
def check_input_type_annotation(cls, pytype: type) -> bool:
adf = get_durable_package()
return issubclass(pytype, (str, bytes, adf.DurableFunctionsClient))

@classmethod
def check_output_type_annotation(cls, pytype: type) -> bool:
return issubclass(pytype, (str, bytes, bytearray))

@classmethod
def encode(cls, obj: typing.Any, *,
expected_type: typing.Optional[type]) -> meta.Datum:
if isinstance(obj, str):
return meta.Datum(type='string', value=obj)

elif isinstance(obj, (bytes, bytearray)):
return meta.Datum(type='bytes', value=bytes(obj))
elif obj is None:
return meta.Datum(type=None, value=obj)
elif isinstance(obj, dict):
return meta.Datum(type='dict', value=obj)
elif isinstance(obj, list):
return meta.Datum(type='list', value=obj)
elif isinstance(obj, bool):
return meta.Datum(type='bool', value=obj)
elif isinstance(obj, int):
return meta.Datum(type='int', value=obj)
elif isinstance(obj, float):
return meta.Datum(type='double', value=obj)
else:
raise NotImplementedError

@classmethod
def decode(cls, data: meta.Datum, *, trigger_metadata) -> typing.Any:
adf = get_durable_package()
return adf.DurableFunctionsClient(data.value)


def register_durable_converters():
"""
Registers the appropriate Durable Functions converters based on the
installed Durable Functions package.
"""
pkg = get_durable_package()
if pkg is None:
return

meta._ConverterMeta._bindings.pop("orchestrationTrigger", None)
meta._ConverterMeta._bindings.pop("entityTrigger", None)
meta._ConverterMeta._bindings.pop("activityTrigger", None)
meta._ConverterMeta._bindings.pop("durableClient", None)

if hasattr(pkg, 'version') and pkg.version.startswith("2."):
_logger.debug("Registering Durable Task Durable Functions converters.")
meta._ConverterMeta._bindings["orchestrationTrigger"] = OrchestrationTriggerConverter
meta._ConverterMeta._bindings["entityTrigger"] = EnitityTriggerConverter
meta._ConverterMeta._bindings["activityTrigger"] = ActivityTriggerConverter
meta._ConverterMeta._bindings["durableClient"] = DurableClientConverter
else:
_logger.debug("Registering Legacy Durable Functions converters.")
meta._ConverterMeta._bindings["orchestrationTrigger"] = LegacyOrchestrationTriggerConverter
meta._ConverterMeta._bindings["entityTrigger"] = LegacyEnitityTriggerConverter
meta._ConverterMeta._bindings["activityTrigger"] = LegacyActivityTriggerConverter
meta._ConverterMeta._bindings["durableClient"] = LegacyDurableClientConverter
Loading
Loading