Skip to content

Commit df9932d

Browse files
committed
Serialization compat, fix typing, working again
1 parent 643e6ea commit df9932d

8 files changed

Lines changed: 149 additions & 50 deletions

File tree

azure-functions-durable/azure/durable_functions/__init__.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,10 @@
11
# Copyright (c) Microsoft Corporation.
22
# Licensed under the MIT License.
33

4-
from .internal.functions_json import install_custom_serialization
54
from .decorators.durable_app import Blueprint, DFApp
65
from .client import DurableFunctionsClient
76
from .orchestrator import Orchestrator
87

9-
# Ensure the durabletask JSON encoder/decoder is replaced as soon as the
10-
# durable_functions package is imported.
11-
install_custom_serialization()
12-
138
# IMPORTANT: DO NOT REMOVE. `azure-functions` relies on the presence and value of this variable
149
# for version detection
1510
version = "2.x"

azure-functions-durable/azure/durable_functions/client.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
from durabletask.client import TaskHubGrpcClient
1111
from .internal.azurefunctions_grpc_interceptor import AzureFunctionsDefaultClientInterceptorImpl
12+
from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
1213
from .http import HttpManagementPayload
1314

1415

@@ -52,7 +53,8 @@ def __init__(self, client_as_string: str):
5253
host_address=self.rpcBaseUrl,
5354
secure_channel=False,
5455
metadata=None,
55-
interceptors=interceptors)
56+
interceptors=interceptors,
57+
data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
5658

5759
def _parse_client_configuration(self, client_as_string: str) -> None:
5860
"""Parses the client configuration JSON string and sets instance variables.

azure-functions-durable/azure/durable_functions/decorators/durable_app.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from functools import wraps
55
from typing import Any, Callable, Optional, Union
66

7+
import azure.functions as func
78
from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel
89
from azure.functions.decorators.function_app import FunctionBuilder
910

@@ -95,7 +96,12 @@ def decorator(entity_func: task.Entity[Any, Any]) -> FunctionBuilder:
9596
# TODO: Because this handle method is the one actually exposed to the Functions SDK decorator,
9697
# the parameter name will always be "context" here, even if the user specified a different name.
9798
# We need to find a way to allow custom context names (like "ctx").
98-
def handle(context: Any) -> str:
99+
# The generated handle is what the Azure Functions host registers,
100+
# so its ``context`` parameter must be annotated with
101+
# ``azure.functions.EntityContext`` for the host's entityTrigger
102+
# binding converter to accept it; at runtime the host passes that
103+
# transport context (exposing ``.body``).
104+
def handle(context: func.EntityContext) -> str:
99105
return DurableFunctionsWorker().execute_entity_batch_request(entity_func, context)
100106

101107
handle.entity_function = entity_func # pyright: ignore[reportFunctionMemberAccess]

azure-functions-durable/azure/durable_functions/internal/functions_json.py

Lines changed: 0 additions & 37 deletions
This file was deleted.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
"""Azure Functions payload serialization for Durable Task.
5+
6+
Bridges durabletask's pluggable :class:`~durabletask.serialization.DataConverter`
7+
to the azure-functions SDK's centralized ``df_dumps`` / ``df_loads`` serializers
8+
so that payloads round-trip through the **exact** wire format the Durable
9+
Functions host extension (and the SDK's ``ActivityTriggerConverter``) expect:
10+
builtins as plain JSON, custom objects wrapped in the
11+
``{"__class__", "__module__", "__data__"}`` envelope via their ``to_json`` /
12+
``from_json`` hooks.
13+
14+
When the installed ``azure-functions`` package exposes ``df_dumps`` / ``df_loads``
15+
(centralized serializers with optional type validation and strict-typing
16+
support), they are used directly. On older releases that lack them we fall back
17+
to the legacy ``_serialize_custom_object`` / ``_deserialize_custom_object`` hooks
18+
-- the same behavior the SDK converter uses in those versions -- keeping both
19+
sides symmetric. The wire format is unchanged either way.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import importlib
25+
import json
26+
import logging
27+
from typing import Any, Callable, Optional, cast
28+
29+
from durabletask.serialization import JsonDataConverter
30+
31+
logger = logging.getLogger("azure.functions.DurableFunctions")
32+
33+
# ``azure.functions`` only exposes its Durable serialization helpers from a
34+
# private, untyped module. Resolve it dynamically and bind the symbols we need
35+
# to locally-typed callables so the rest of the module stays type-checked.
36+
_df_internal = importlib.import_module("azure.functions._durable_functions")
37+
_serialize_custom_object: Callable[[Any], Any] = getattr(
38+
_df_internal, "_serialize_custom_object")
39+
_deserialize_custom_object: Callable[[dict[str, Any]], Any] = getattr(
40+
_df_internal, "_deserialize_custom_object")
41+
42+
_FALLBACK_MESSAGE = (
43+
"The installed 'azure-functions' package does not provide the centralized "
44+
"'df_dumps' / 'df_loads' serializers. Durable Functions is falling back to "
45+
"the legacy serialization pipeline; the wire format is unchanged, but "
46+
"payload type validation (the 'expected_type' argument and strict typing "
47+
"mode) is unavailable. Upgrade to azure-functions>=1.26.0b4 to enable "
48+
"type-validated serialization."
49+
)
50+
51+
_warned = False
52+
53+
54+
def _warn_fallback_once() -> None:
55+
# Deferred to first use (debug level) rather than emitted at import time, so
56+
# users who never exercise the fallback path are not spammed.
57+
global _warned
58+
if not _warned:
59+
_warned = True
60+
logger.debug(_FALLBACK_MESSAGE)
61+
62+
63+
def _fallback_df_dumps(value: Any) -> str:
64+
"""Serialize ``value`` via the legacy custom-object hook."""
65+
_warn_fallback_once()
66+
return json.dumps(value, default=_serialize_custom_object)
67+
68+
69+
def _fallback_df_loads(s: str, expected_type: Optional[type] = None) -> Any:
70+
"""Deserialize ``s`` via the legacy custom-object hook.
71+
72+
``expected_type`` is accepted for call-site compatibility but ignored on
73+
this fallback path; type validation is only performed by the SDK's
74+
``df_loads`` when it is available.
75+
"""
76+
_warn_fallback_once()
77+
return json.loads(s, object_hook=_deserialize_custom_object)
78+
79+
80+
# Prefer the SDK's centralized serializers; fall back to the legacy hooks when
81+
# they are unavailable (older azure-functions releases).
82+
_sdk_df_dumps = getattr(_df_internal, "df_dumps", None)
83+
_sdk_df_loads = getattr(_df_internal, "df_loads", None)
84+
85+
df_dumps: Callable[[Any], str] = (
86+
cast("Callable[[Any], str]", _sdk_df_dumps)
87+
if callable(_sdk_df_dumps) else _fallback_df_dumps)
88+
df_loads: Callable[..., Any] = (
89+
cast("Callable[..., Any]", _sdk_df_loads)
90+
if callable(_sdk_df_loads) else _fallback_df_loads)
91+
92+
93+
class FunctionsDataConverter(JsonDataConverter):
94+
""":class:`DataConverter` that serializes via azure-functions' codec.
95+
96+
Overrides only the string boundary (:meth:`serialize` / :meth:`deserialize`)
97+
to route through ``df_dumps`` / ``df_loads`` -- producing the
98+
``{"__class__", "__module__", "__data__"}`` envelope that the Durable
99+
Functions host expects -- while inheriting :class:`JsonDataConverter`'s
100+
value-level :meth:`coerce` and reconstruction policy
101+
(:meth:`can_reconstruct`), which operate on already-parsed values and are
102+
wire-format agnostic.
103+
"""
104+
105+
def serialize(self, value: Any) -> str | None:
106+
if value is None:
107+
return None
108+
return df_dumps(value)
109+
110+
def deserialize(self, data: str | None, target_type: type | None = None) -> Any:
111+
if data is None or data == "":
112+
return None
113+
return df_loads(data, target_type)
114+
115+
116+
# Shared instance: the converter is stateless, so a single instance is reused
117+
# across the per-invocation worker/client objects.
118+
DEFAULT_FUNCTIONS_DATA_CONVERTER: FunctionsDataConverter = FunctionsDataConverter()

azure-functions-durable/azure/durable_functions/orchestrator.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
"""
66
from typing import Any, Callable, Generator
77

8+
import azure.functions as func
9+
810
from durabletask.task import OrchestrationContext
911

1012
from .worker import DurableFunctionsWorker
@@ -27,13 +29,16 @@ def __init__(self,
2729
"""
2830
self.fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, Any]] = activity_func
2931

30-
def handle(self, context: OrchestrationContext) -> str:
32+
def handle(self, context: func.OrchestrationContext) -> str:
3133
"""Handle the orchestration of the user defined generator function.
3234
3335
Parameters
3436
----------
35-
context : DurableOrchestrationContext
36-
The DF orchestration context
37+
context : azure.functions.OrchestrationContext
38+
The Durable Functions orchestration trigger context. This is the
39+
transport wrapper supplied by the host (it exposes ``.body``); the
40+
user's orchestrator function receives a durabletask
41+
``OrchestrationContext`` during execution inside the worker.
3742
3843
Returns
3944
-------
@@ -60,7 +65,12 @@ def create(cls, fn: Callable[[OrchestrationContext, Any], Generator[Any, Any, An
6065
Handle function of the newly created orchestration client
6166
"""
6267

63-
def handle(context: Any) -> str:
68+
# The generated handle is the function registered with the Azure
69+
# Functions host. Its ``context`` parameter must be annotated with
70+
# ``azure.functions.OrchestrationContext`` so the host's
71+
# orchestrationTrigger binding converter accepts it; at runtime the
72+
# host passes that transport context (exposing ``.body``).
73+
def handle(context: func.OrchestrationContext) -> str:
6474
return Orchestrator(fn).handle(context)
6575

6676
handle.orchestrator_function = fn # pyright: ignore[reportFunctionMemberAccess]

azure-functions-durable/azure/durable_functions/worker.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
)
1515
from durabletask.worker import TaskHubGrpcWorker
1616
from .internal.azurefunctions_null_stub import AzureFunctionsNullStub
17+
from .internal.serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
1718

1819

1920
# Worker class used for Durable Task Scheduler (DTS)
@@ -30,7 +31,11 @@ def __init__(self) -> None:
3031
# concurrency options, payload store, etc.) that the inherited
3132
# ``_execute_*`` methods rely on; work items are delivered directly by
3233
# the methods below rather than streamed from a sidecar.
33-
super().__init__()
34+
#
35+
# The Functions converter routes payload serialization through the
36+
# azure-functions codec (df_dumps/df_loads) so user types round-trip in
37+
# the wire format the Durable Functions host extension expects.
38+
super().__init__(data_converter=DEFAULT_FUNCTIONS_DATA_CONVERTER)
3439

3540
def add_named_orchestrator(self, name: str, func: task.Orchestrator[Any, Any]) -> None:
3641
self._registry.add_named_orchestrator(name, func)

azure-functions-durable/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ readme = "README.md"
2929
dependencies = [
3030
"durabletask>=1.2.0dev0",
3131
"azure-identity>=1.19.0",
32-
"azure-functions>=1.25.0b3.dev1"
32+
"azure-functions>=2.2.0b6"
3333
]
3434

3535
[project.urls]

0 commit comments

Comments
 (0)