Skip to content

Commit 1fbfcd2

Browse files
committed
change decorators & converters based on durable package
1 parent 8bd30d3 commit 1fbfcd2

File tree

4 files changed

+301
-17
lines changed

4 files changed

+301
-17
lines changed

azure/functions/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
AsgiFunctionApp, WsgiFunctionApp,
1313
ExternalHttpFunctionApp, BlobSource, McpPropertyType)
1414
from ._durable_functions import OrchestrationContext, EntityContext
15+
from .durable_functions import register_durable_converters
1516
from .decorators.function_app import (FunctionRegister, TriggerApi,
1617
BindingApi, SettingsApi)
1718
from .extension import (ExtensionMeta, FunctionExtensionException,
@@ -43,6 +44,9 @@
4344
from . import mysql # NoQA
4445

4546

47+
register_durable_converters()
48+
49+
4650
__all__ = (
4751
# Functions
4852
'get_binding_registry',
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
import logging
4+
5+
_logger = logging.getLogger('azure.functions.AsgiMiddleware')
6+
7+
df = None
8+
9+
10+
def get_durable_package():
11+
"""Determines which Durable SDK is being used.
12+
13+
If the `azure-functions-durable` package is installed, we
14+
log a warning that this legacy package
15+
is deprecated.
16+
17+
If both the legacy and current packages are installed,
18+
we log a warning and prefer the current package.
19+
20+
If neither package is installed, we raise an exception.
21+
"""
22+
_logger.info("Attempting to import Durable Functions package.")
23+
using_legacy = False
24+
using_durable_task = False
25+
global df
26+
if df:
27+
_logger.info("Durable Functions package already loaded. DF: %s", df)
28+
return df
29+
30+
try:
31+
import azure.durable_functions as durable_functions
32+
using_legacy = True
33+
_logger.warning("`azure-functions-durable` is deprecated. " \
34+
"Please migrate to the new `durabletask-azurefunctions` package. " \
35+
"See <AKA.MS LINK HERE> for more details.")
36+
except ImportError:
37+
_logger.info("`azure-functions-durable` package not found.")
38+
pass
39+
try:
40+
import durabletask.azurefunctions as durable_functions
41+
using_durable_task = True
42+
except ImportError:
43+
_logger.info("`durabletask-azurefunctions` package not found.")
44+
pass
45+
46+
if using_durable_task and using_legacy:
47+
# Both packages are installed; prefer `durabletask-azurefunctions`.
48+
_logger.warning("Both `azure-functions-durable` and " \
49+
"`durabletask-azurefunctions` packages are installed. " \
50+
"The `durabletask-azurefunctions` package will be used.")
51+
52+
if not using_durable_task and not using_legacy:
53+
error_message = \
54+
"Attempted to use a Durable Functions decorator, " \
55+
"but the `durabletask-azurefunctions` SDK package could not be " \
56+
"found. Please install `durabletask-azurefunctions` to use " \
57+
"Durable Functions."
58+
raise Exception(error_message)
59+
60+
df = durable_functions
61+
62+
return durable_functions

azure/functions/decorators/function_app.py

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
DaprBindingTrigger, DaprInvokeOutput, DaprPublishOutput, \
2525
DaprSecretInput, DaprServiceInvocationTrigger, DaprStateInput, \
2626
DaprStateOutput, DaprTopicTrigger
27+
from azure.functions.decorators.durable_functions import get_durable_package
2728
from azure.functions.decorators.eventgrid import EventGridTrigger, \
2829
EventGridOutput
2930
from azure.functions.decorators.eventhub import EventHubTrigger, EventHubOutput
@@ -57,6 +58,7 @@
5758
from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \
5859
MySqlTrigger
5960

61+
_logger = logging.getLogger('azure.functions.AsgiMiddleware')
6062

6163
class Function(object):
6264
"""
@@ -347,17 +349,11 @@ def _get_durable_blueprint(self):
347349
"""Attempt to import the Durable Functions SDK from which DF
348350
decorators are implemented.
349351
"""
350-
try:
351-
import azure.durable_functions as df
352-
df_bp = df.Blueprint()
353-
return df_bp
354-
except ImportError:
355-
error_message = \
356-
"Attempted to use a Durable Functions decorator, " \
357-
"but the `azure-functions-durable` SDK package could not be " \
358-
"found. Please install `azure-functions-durable` to use " \
359-
"Durable Functions."
360-
raise Exception(error_message)
352+
_logger.info("Getting Durable Functions blueprint.")
353+
df = get_durable_package()
354+
df_bp = df.Blueprint()
355+
return df_bp
356+
361357

362358
@property
363359
def app_script_file(self) -> str:

azure/functions/durable_functions.py

Lines changed: 228 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@
55
import json
66

77
from azure.functions import _durable_functions
8+
from azure.functions.decorators.durable_functions import get_durable_package
89
from . import meta
910

11+
import logging
12+
_logger = logging.getLogger('azure.functions.AsgiMiddleware')
1013

11-
# Durable Function Orchestration Trigger
12-
class OrchestrationTriggerConverter(meta.InConverter,
14+
# ---------------- Legacy Durable Functions Converters ---------------- #
15+
# Legacy Durable Function Orchestration Trigger
16+
class LegacyOrchestrationTriggerConverter(meta.InConverter,
1317
meta.OutConverter,
14-
binding='orchestrationTrigger',
18+
binding=None,
1519
trigger=True):
1620
@classmethod
1721
def check_input_type_annotation(cls, pytype):
@@ -39,9 +43,196 @@ def has_implicit_output(cls) -> bool:
3943
return True
4044

4145

46+
# Legacy Durable Function Entity Trigger
47+
class LegacyEnitityTriggerConverter(meta.InConverter,
48+
meta.OutConverter,
49+
binding=None,
50+
trigger=True):
51+
@classmethod
52+
def check_input_type_annotation(cls, pytype):
53+
return issubclass(pytype, _durable_functions.EntityContext)
54+
55+
@classmethod
56+
def check_output_type_annotation(cls, pytype):
57+
# Implicit output should accept any return type
58+
return True
59+
60+
@classmethod
61+
def decode(cls,
62+
data: meta.Datum, *,
63+
trigger_metadata) -> _durable_functions.EntityContext:
64+
return _durable_functions.EntityContext(data.value)
65+
66+
@classmethod
67+
def encode(cls, obj: typing.Any, *,
68+
expected_type: typing.Optional[type]) -> meta.Datum:
69+
# Durable function context should be a json
70+
return meta.Datum(type='json', value=obj)
71+
72+
@classmethod
73+
def has_implicit_output(cls) -> bool:
74+
return True
75+
76+
77+
# Legacy Durable Function Activity Trigger
78+
class LegacyActivityTriggerConverter(meta.InConverter,
79+
meta.OutConverter,
80+
binding=None,
81+
trigger=True):
82+
@classmethod
83+
def check_input_type_annotation(cls, pytype):
84+
# Activity Trigger's arguments should accept any types
85+
return True
86+
87+
@classmethod
88+
def check_output_type_annotation(cls, pytype):
89+
# The activity trigger should accept any JSON serializable types
90+
return True
91+
92+
@classmethod
93+
def decode(cls,
94+
data: meta.Datum, *,
95+
trigger_metadata) -> typing.Any:
96+
data_type = data.type
97+
98+
# Durable functions extension always returns a string of json
99+
# See durable functions library's call_activity_task docs
100+
if data_type in ['string', 'json']:
101+
try:
102+
callback = _durable_functions._deserialize_custom_object
103+
result = json.loads(data.value, object_hook=callback)
104+
except json.JSONDecodeError:
105+
# String failover if the content is not json serializable
106+
result = data.value
107+
except Exception as e:
108+
raise ValueError(
109+
'activity trigger input must be a string or a '
110+
f'valid json serializable ({data.value})') from e
111+
else:
112+
raise NotImplementedError(
113+
f'unsupported activity trigger payload type: {data_type}')
114+
115+
return result
116+
117+
@classmethod
118+
def encode(cls, obj: typing.Any, *,
119+
expected_type: typing.Optional[type]) -> meta.Datum:
120+
try:
121+
callback = _durable_functions._serialize_custom_object
122+
result = json.dumps(obj, default=callback)
123+
except TypeError as e:
124+
raise ValueError(
125+
f'activity trigger output must be json serializable ({obj})') from e
126+
127+
return meta.Datum(type='json', value=result)
128+
129+
@classmethod
130+
def has_implicit_output(cls) -> bool:
131+
return True
132+
133+
134+
# Legacy Durable Functions Durable Client Bindings
135+
class LegacyDurableClientConverter(meta.InConverter,
136+
meta.OutConverter,
137+
binding=None):
138+
@classmethod
139+
def has_implicit_output(cls) -> bool:
140+
return False
141+
142+
@classmethod
143+
def has_trigger_support(cls) -> bool:
144+
return False
145+
146+
@classmethod
147+
def check_input_type_annotation(cls, pytype: type) -> bool:
148+
return issubclass(pytype, (str, bytes))
149+
150+
@classmethod
151+
def check_output_type_annotation(cls, pytype: type) -> bool:
152+
return issubclass(pytype, (str, bytes, bytearray))
153+
154+
@classmethod
155+
def encode(cls, obj: typing.Any, *,
156+
expected_type: typing.Optional[type]) -> meta.Datum:
157+
if isinstance(obj, str):
158+
return meta.Datum(type='string', value=obj)
159+
160+
elif isinstance(obj, (bytes, bytearray)):
161+
return meta.Datum(type='bytes', value=bytes(obj))
162+
elif obj is None:
163+
return meta.Datum(type=None, value=obj)
164+
elif isinstance(obj, dict):
165+
return meta.Datum(type='dict', value=obj)
166+
elif isinstance(obj, list):
167+
return meta.Datum(type='list', value=obj)
168+
elif isinstance(obj, bool):
169+
return meta.Datum(type='bool', value=obj)
170+
elif isinstance(obj, int):
171+
return meta.Datum(type='int', value=obj)
172+
elif isinstance(obj, float):
173+
return meta.Datum(type='double', value=obj)
174+
else:
175+
raise NotImplementedError
176+
177+
@classmethod
178+
def decode(cls, data: meta.Datum, *, trigger_metadata) -> typing.Any:
179+
if data is None:
180+
return None
181+
data_type = data.type
182+
183+
if data_type == 'string':
184+
result = data.value
185+
elif data_type == 'bytes':
186+
result = data.value
187+
elif data_type == 'json':
188+
result = data.value
189+
elif data_type is None:
190+
result = None
191+
else:
192+
raise ValueError(
193+
'unexpected type of data received for the "generic" binding ',
194+
repr(data_type)
195+
)
196+
197+
return result
198+
199+
200+
# ---------------- Durable Task Durable Functions Converters ---------------- #
201+
# Durable Function Orchestration Trigger
202+
class OrchestrationTriggerConverter(meta.InConverter,
203+
meta.OutConverter,
204+
binding=None,
205+
trigger=True):
206+
@classmethod
207+
def check_input_type_annotation(cls, pytype):
208+
return issubclass(pytype, _durable_functions.OrchestrationContext)
209+
210+
@classmethod
211+
def check_output_type_annotation(cls, pytype):
212+
# Implicit output should accept any return type
213+
return True
214+
215+
@classmethod
216+
def decode(cls,
217+
data: meta.Datum, *,
218+
trigger_metadata) -> _durable_functions.OrchestrationContext:
219+
return _durable_functions.OrchestrationContext(data.value)
220+
221+
@classmethod
222+
def encode(cls, obj: typing.Any, *,
223+
expected_type: typing.Optional[type]) -> meta.Datum:
224+
# Durable function context should be a string
225+
return meta.Datum(type='string', value=obj)
226+
227+
@classmethod
228+
def has_implicit_output(cls) -> bool:
229+
return True
230+
231+
232+
# Durable Function Entity Trigger
42233
class EnitityTriggerConverter(meta.InConverter,
43234
meta.OutConverter,
44-
binding='entityTrigger',
235+
binding=None,
45236
trigger=True):
46237
@classmethod
47238
def check_input_type_annotation(cls, pytype):
@@ -72,7 +263,7 @@ def has_implicit_output(cls) -> bool:
72263
# Durable Function Activity Trigger
73264
class ActivityTriggerConverter(meta.InConverter,
74265
meta.OutConverter,
75-
binding='activityTrigger',
266+
binding=None,
76267
trigger=True):
77268
@classmethod
78269
def check_input_type_annotation(cls, pytype):
@@ -129,7 +320,7 @@ def has_implicit_output(cls) -> bool:
129320
# Durable Functions Durable Client Bindings
130321
class DurableClientConverter(meta.InConverter,
131322
meta.OutConverter,
132-
binding='durableClient'):
323+
binding=None):
133324
@classmethod
134325
def has_implicit_output(cls) -> bool:
135326
return False
@@ -190,3 +381,34 @@ def decode(cls, data: meta.Datum, *, trigger_metadata) -> typing.Any:
190381
)
191382

192383
return result
384+
385+
386+
def register_durable_converters():
387+
_logger.info("Registering Durable Functions converters based on ")
388+
pkg = get_durable_package()
389+
if pkg is None:
390+
# Durable library not installed → do nothing
391+
return
392+
393+
_logger.info("Durable Functions package loaded: %s", pkg.__name__)
394+
_logger.info("Current bindings before registration: %s", meta._ConverterMeta._bindings)
395+
# Clear existing bindings if they exist
396+
meta._ConverterMeta._bindings.pop("orchestrationTrigger", None)
397+
meta._ConverterMeta._bindings.pop("entityTrigger", None)
398+
meta._ConverterMeta._bindings.pop("activityTrigger", None)
399+
meta._ConverterMeta._bindings.pop("durableClient", None)
400+
401+
if pkg.__name__ == "azure.durable_functions":
402+
_logger.info("Registering Legacy Durable Functions converters.")
403+
meta._ConverterMeta._bindings["orchestrationTrigger"] = LegacyOrchestrationTriggerConverter
404+
meta._ConverterMeta._bindings["entityTrigger"] = LegacyEnitityTriggerConverter
405+
meta._ConverterMeta._bindings["activityTrigger"] = LegacyActivityTriggerConverter
406+
meta._ConverterMeta._bindings["durableClient"] = LegacyDurableClientConverter
407+
else:
408+
_logger.info("Registering Durable Task Durable Functions converters.")
409+
meta._ConverterMeta._bindings["orchestrationTrigger"] = OrchestrationTriggerConverter
410+
meta._ConverterMeta._bindings["entityTrigger"] = EnitityTriggerConverter
411+
meta._ConverterMeta._bindings["activityTrigger"] = ActivityTriggerConverter
412+
meta._ConverterMeta._bindings["durableClient"] = DurableClientConverter
413+
_logger.info("Durable Functions converters registered.")
414+
_logger.info("Current bindings after registration: %s", meta._ConverterMeta._bindings)

0 commit comments

Comments
 (0)