Skip to content

Commit 46570e9

Browse files
committed
Add missing type coersion, rich client, tests
1 parent 3c05264 commit 46570e9

4 files changed

Lines changed: 260 additions & 10 deletions

File tree

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

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger, \
1414
DurableClient
15+
from ..client import DurableFunctionsClient
1516
from ..worker import DurableFunctionsWorker
1617
from ..orchestrator import Orchestrator
1718

@@ -47,14 +48,19 @@ def __init__(self,
4748

4849
def _configure_orchestrator_callable(
4950
self,
50-
wrap: Callable[[Callable[..., Any]], FunctionBuilder]
51+
wrap: Callable[[Callable[..., Any]], FunctionBuilder],
52+
input_type: Optional[type] = None
5153
) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]:
5254
"""Obtain decorator to construct an Orchestrator class from a user-defined Function.
5355
5456
Parameters
5557
----------
5658
wrap: Callable
5759
The next decorator to be applied.
60+
input_type: Optional[type]
61+
The expected type for orchestration input, forwarded from the
62+
``orchestration_trigger`` decorator so a v1-style
63+
``context.get_input()`` can decode the input to that type.
5864
5965
Returns
6066
-------
@@ -65,6 +71,11 @@ def _configure_orchestrator_callable(
6571
def decorator(orchestrator_func: task.Orchestrator[Any, Any]) -> FunctionBuilder:
6672
# Construct an orchestrator based on the end-user code
6773

74+
if input_type is not None:
75+
# Stash the decorator-declared input type so the runtime can
76+
# feed it to a v1-style ``context.get_input()``.
77+
orchestrator_func._df_input_type = input_type # type: ignore[attr-defined] # noqa: E501
78+
6879
handle = Orchestrator.create(orchestrator_func)
6980

7081
# invoke next decorator, with the Orchestrator as input
@@ -167,7 +178,8 @@ def decorator(func: Callable[..., Any]) -> FunctionBuilder:
167178
return decorator
168179

169180
def orchestration_trigger(self, context_name: str,
170-
orchestration: Optional[str] = None
181+
orchestration: Optional[str] = None,
182+
input_type: Optional[type] = None
171183
) -> Callable[[task.Orchestrator[Any, Any]], FunctionBuilder]:
172184
"""Register an Orchestrator Function.
173185
@@ -178,8 +190,12 @@ def orchestration_trigger(self, context_name: str,
178190
orchestration: Optional[str]
179191
Name of Orchestrator Function.
180192
The value is None by default, in which case the name of the method is used.
193+
input_type: Optional[type]
194+
The expected type for the orchestration input. When set, a v1-style
195+
``context.get_input()`` decodes the input payload to this type. A
196+
call-site ``expected_type`` argument on ``get_input`` takes
197+
precedence over this value.
181198
"""
182-
@self._configure_orchestrator_callable
183199
@self._build_function
184200
def wrap(fb: FunctionBuilder) -> FunctionBuilder:
185201

@@ -191,7 +207,7 @@ def decorator() -> FunctionBuilder:
191207

192208
return decorator()
193209

194-
return wrap
210+
return self._configure_orchestrator_callable(wrap, input_type=input_type)
195211

196212
def activity_trigger(self, input_name: str,
197213
activity: Optional[str] = None
@@ -271,6 +287,7 @@ def durable_client_input(self,
271287
@self._build_function
272288
def wrap(fb: FunctionBuilder) -> FunctionBuilder:
273289
def decorator() -> FunctionBuilder:
290+
self._add_rich_client(fb, client_name, DurableFunctionsClient)
274291
fb.add_binding(
275292
binding=DurableClient(name=client_name,
276293
task_hub=task_hub,

azure-functions-durable/azure/durable_functions/internal/compat/orchestration_context.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from durabletask.entities import EntityInstanceId
1111
from durabletask.task import OrchestrationContext, RetryPolicy, Task
1212

13+
from ..serialization import DEFAULT_FUNCTIONS_DATA_CONVERTER
1314
from .token_source import TokenSource
1415

1516

@@ -24,16 +25,28 @@ class DurableOrchestrationContext:
2425
context directly instead.
2526
"""
2627

27-
def __init__(self, ctx: OrchestrationContext, orchestration_input: Any = None):
28+
def __init__(self,
29+
ctx: OrchestrationContext,
30+
orchestration_input: Any = None,
31+
input_type: Optional[type] = None):
2832
self._ctx = ctx
2933
self._input = orchestration_input
34+
self._input_type = input_type
3035
self._custom_status: Any = None
3136
self._will_continue_as_new = False
3237

3338
# -- input ---------------------------------------------------------------
34-
def get_input(self) -> Any:
35-
"""Get the orchestration input."""
36-
return self._input
39+
def get_input(self, expected_type: Optional[type] = None) -> Any:
40+
"""Get the orchestration input.
41+
42+
When an ``expected_type`` (or the ``input_type`` declared on the
43+
``orchestration_trigger`` decorator) is available, the already-decoded
44+
input is coerced to that type; otherwise the raw value is returned.
45+
"""
46+
resolved_type = expected_type or self._input_type
47+
if resolved_type is None:
48+
return self._input
49+
return DEFAULT_FUNCTIONS_DATA_CONVERTER.coerce(self._input, resolved_type)
3750

3851
# -- properties ----------------------------------------------------------
3952
@property
@@ -227,19 +240,20 @@ def wrap_orchestrator(fn: Callable[..., Any]) -> Callable[..., Any]:
227240
if accepts_two_positional_args(fn):
228241
return fn
229242

243+
input_type = getattr(fn, "_df_input_type", None)
230244
name = getattr(fn, "__name__", "orchestrator")
231245

232246
if inspect.isgeneratorfunction(fn):
233247
def _generator_wrapper(context: OrchestrationContext, _input: Any = None) -> Any:
234-
adapter = DurableOrchestrationContext(context, _input)
248+
adapter = DurableOrchestrationContext(context, _input, input_type)
235249
generator = cast("Generator[Any, Any, Any]", fn(adapter))
236250
result: Any = yield from generator
237251
return result
238252
_generator_wrapper.__name__ = name
239253
return _generator_wrapper
240254

241255
def _wrapper(context: OrchestrationContext, _input: Any = None) -> Any:
242-
adapter = DurableOrchestrationContext(context, _input)
256+
adapter = DurableOrchestrationContext(context, _input, input_type)
243257
return fn(adapter)
244258
_wrapper.__name__ = name
245259
return _wrapper
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import json
5+
6+
import azure.durable_functions as df
7+
from azure.durable_functions import DurableFunctionsClient
8+
from azure.durable_functions.constants import (
9+
ACTIVITY_TRIGGER,
10+
DURABLE_CLIENT,
11+
ENTITY_TRIGGER,
12+
ORCHESTRATION_TRIGGER,
13+
)
14+
15+
16+
_CLIENT_CONFIG = json.dumps({
17+
"taskHubName": "TestHub",
18+
"requiredQueryStringParameters": "code=xyz",
19+
"baseUrl": "http://localhost:7071/runtime/webhooks/durabletask",
20+
"rpcBaseUrl": "http://localhost:8080/",
21+
})
22+
23+
24+
def _trigger(fb):
25+
return fb._function.get_trigger()
26+
27+
28+
# ---------------------------------------------------------------------------
29+
# orchestration_trigger
30+
# ---------------------------------------------------------------------------
31+
32+
def test_orchestration_trigger_v1_signature():
33+
app = df.DFApp()
34+
35+
def my_orchestrator(context):
36+
return 1
37+
38+
fb = app.orchestration_trigger(
39+
context_name="context", orchestration="MyOrchestrator")(my_orchestrator)
40+
trigger = _trigger(fb)
41+
assert trigger.get_binding_name() == ORCHESTRATION_TRIGGER
42+
assert trigger.name == "context"
43+
assert trigger.orchestration == "MyOrchestrator"
44+
45+
46+
def test_orchestration_trigger_accepts_input_type():
47+
app = df.DFApp()
48+
49+
def my_orchestrator(context):
50+
return 1
51+
52+
# v1 parity: the input_type keyword must be accepted and stashed.
53+
fb = app.orchestration_trigger(
54+
context_name="context", input_type=dict)(my_orchestrator)
55+
assert fb is not None
56+
assert my_orchestrator._df_input_type is dict
57+
58+
59+
# ---------------------------------------------------------------------------
60+
# activity_trigger
61+
# ---------------------------------------------------------------------------
62+
63+
def test_activity_trigger_v1_signature():
64+
app = df.DFApp()
65+
66+
def my_activity(myinput):
67+
return myinput
68+
69+
fb = app.activity_trigger(
70+
input_name="myinput", activity="MyActivity")(my_activity)
71+
trigger = _trigger(fb)
72+
assert trigger.get_binding_name() == ACTIVITY_TRIGGER
73+
assert trigger.name == "myinput"
74+
assert trigger.activity == "MyActivity"
75+
76+
77+
# ---------------------------------------------------------------------------
78+
# entity_trigger
79+
# ---------------------------------------------------------------------------
80+
81+
def test_entity_trigger_v1_signature():
82+
app = df.DFApp()
83+
84+
def my_entity(context):
85+
return None
86+
87+
fb = app.entity_trigger(
88+
context_name="context", entity_name="MyEntity")(my_entity)
89+
trigger = _trigger(fb)
90+
assert trigger.get_binding_name() == ENTITY_TRIGGER
91+
assert trigger.name == "context"
92+
assert trigger.entity_name == "MyEntity"
93+
94+
95+
# ---------------------------------------------------------------------------
96+
# durable_client_input
97+
# ---------------------------------------------------------------------------
98+
99+
def test_durable_client_input_v1_signature_registers_binding():
100+
app = df.DFApp()
101+
102+
async def starter(client):
103+
return None
104+
105+
fb = app.durable_client_input(
106+
client_name="client", task_hub="hub", connection_name="conn")(starter)
107+
bindings = fb._function.get_bindings()
108+
client_bindings = [b for b in bindings if b.get_binding_name() == DURABLE_CLIENT]
109+
assert len(client_bindings) == 1
110+
binding = client_bindings[0]
111+
assert binding.name == "client"
112+
assert binding.task_hub == "hub"
113+
assert binding.connection_name == "conn"
114+
115+
116+
async def test_durable_client_input_injects_rich_client():
117+
app = df.DFApp()
118+
received = {}
119+
120+
async def starter(client):
121+
received["client"] = client
122+
123+
fb = app.durable_client_input(client_name="client")(starter)
124+
# _add_rich_client replaces the user function with middleware that builds
125+
# a DurableFunctionsClient from the binding's JSON string.
126+
middleware = fb._function._func
127+
await middleware(client=_CLIENT_CONFIG)
128+
129+
client = received["client"]
130+
assert isinstance(client, DurableFunctionsClient)
131+
try:
132+
assert client.taskHubName == "TestHub"
133+
finally:
134+
await client.close()
135+
136+
137+
# ---------------------------------------------------------------------------
138+
# All decorators register a function builder
139+
# ---------------------------------------------------------------------------
140+
141+
def test_decorators_register_function_builders():
142+
app = df.DFApp()
143+
144+
def orch(context):
145+
return 1
146+
147+
app.orchestration_trigger(context_name="context")(orch)
148+
assert len(app._function_builders) == 1
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# Copyright (c) Microsoft Corporation.
2+
# Licensed under the MIT License.
3+
4+
import pytest
5+
6+
from azure.durable_functions.internal.serialization import (
7+
DEFAULT_FUNCTIONS_DATA_CONVERTER,
8+
)
9+
10+
11+
class Point:
12+
"""Sample custom type using the v1 to_json / from_json convention."""
13+
14+
def __init__(self, x: int, y: int):
15+
self.x = x
16+
self.y = y
17+
18+
def to_json(self):
19+
return {"x": self.x, "y": self.y}
20+
21+
@classmethod
22+
def from_json(cls, data):
23+
return cls(data["x"], data["y"])
24+
25+
def __eq__(self, other):
26+
return isinstance(other, Point) and self.x == other.x and self.y == other.y
27+
28+
29+
def test_custom_object_round_trips():
30+
point = Point(3, 4)
31+
serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(point)
32+
assert isinstance(serialized, str)
33+
34+
restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized)
35+
assert isinstance(restored, Point)
36+
assert restored == point
37+
38+
39+
def test_nested_custom_object_round_trips():
40+
payload = {"points": [Point(1, 1), Point(2, 2)], "label": "path"}
41+
serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(payload)
42+
restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized)
43+
assert restored["label"] == "path"
44+
assert restored["points"] == [Point(1, 1), Point(2, 2)]
45+
46+
47+
@pytest.mark.parametrize("value", [
48+
{"a": 1, "b": [1, 2, 3]},
49+
[1, 2, 3],
50+
"hello",
51+
42,
52+
3.14,
53+
True,
54+
])
55+
def test_builtin_values_round_trip(value):
56+
serialized = DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(value)
57+
assert isinstance(serialized, str)
58+
restored = DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(serialized)
59+
assert restored == value
60+
61+
62+
def test_none_round_trips():
63+
assert DEFAULT_FUNCTIONS_DATA_CONVERTER.serialize(None) is None
64+
assert DEFAULT_FUNCTIONS_DATA_CONVERTER.deserialize(None) is None
65+
66+
67+
def test_coerce_plain_dict_to_type():
68+
# get_input(expected_type=...) relies on the converter coercing a plain
69+
# (already-deserialized) dict into the declared type.
70+
coerced = DEFAULT_FUNCTIONS_DATA_CONVERTER.coerce({"x": 5, "y": 6}, Point)
71+
assert coerced == Point(5, 6)

0 commit comments

Comments
 (0)