Skip to content

Commit 747908f

Browse files
authored
Add support for PyStein programming model (#398)
1 parent 95e794f commit 747908f

17 files changed

+805
-19
lines changed

azure/durable_functions/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .models.DurableEntityContext import DurableEntityContext
1212
from .models.RetryOptions import RetryOptions
1313
from .models.TokenSource import ManagedIdentityTokenSource
14+
from .decorators import DFApp
1415
import json
1516
from pathlib import Path
1617
import sys
@@ -69,5 +70,6 @@ def validate_extension_bundles():
6970
'DurableOrchestrationContext',
7071
'ManagedIdentityTokenSource',
7172
'OrchestrationRuntimeStatus',
72-
'RetryOptions'
73+
'RetryOptions',
74+
'DFApp'
7375
]

azure/durable_functions/constants.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,7 @@
33
DEFAULT_LOCAL_ORIGIN: str = f'http://{DEFAULT_LOCAL_HOST}'
44
DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S.%fZ'
55
HTTP_ACTION_NAME = 'BuiltIn::HttpActivity'
6+
ORCHESTRATION_TRIGGER = "orchestrationTrigger"
7+
ACTIVITY_TRIGGER = "activityTrigger"
8+
ENTITY_TRIGGER = "entityTrigger"
9+
DURABLE_CLIENT = "durableClient"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
"""Decorator definitions for Durable Functions."""
4+
from .durable_app import DFApp
5+
6+
__all__ = [
7+
"DFApp"
8+
]
Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,228 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
from .metadata import OrchestrationTrigger, ActivityTrigger, EntityTrigger,\
4+
DurableClient
5+
from typing import Callable, Optional
6+
from azure.durable_functions.entity import Entity
7+
from azure.durable_functions.orchestrator import Orchestrator
8+
from azure.durable_functions import DurableOrchestrationClient
9+
from typing import Union
10+
from azure.functions import FunctionRegister, TriggerApi, BindingApi, AuthLevel
11+
from functools import wraps
12+
13+
14+
class DFApp(FunctionRegister, TriggerApi, BindingApi):
15+
"""Durable Functions (DF) app.
16+
17+
Exports the decorators required to register DF Function-types.
18+
"""
19+
20+
def __init__(self,
21+
http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION):
22+
"""Instantiate a Durable Functions app with which to register Functions.
23+
24+
Parameters
25+
----------
26+
http_auth_level: Union[AuthLevel, str]
27+
Authorization level required for Function invocation.
28+
Defaults to AuthLevel.Function.
29+
30+
Returns
31+
-------
32+
DFApp
33+
New instance of a Durable Functions app
34+
"""
35+
super().__init__(auth_level=http_auth_level)
36+
37+
def _configure_entity_callable(self, wrap) -> Callable:
38+
"""Obtain decorator to construct an Entity class from a user-defined Function.
39+
40+
In the old programming model, this decorator's logic was unavoidable boilerplate
41+
in user-code. Now, this is handled internally by the framework.
42+
43+
Parameters
44+
----------
45+
wrap: Callable
46+
The next decorator to be applied.
47+
48+
Returns
49+
-------
50+
Callable
51+
The function to construct an Entity class from the user-defined Function,
52+
wrapped by the next decorator in the sequence.
53+
"""
54+
def decorator(entity_func):
55+
# Construct an entity based on the end-user code
56+
handle = Entity.create(entity_func)
57+
58+
# invoke next decorator, with the Entity as input
59+
handle.__name__ = entity_func.__name__
60+
return wrap(handle)
61+
62+
return decorator
63+
64+
def _configure_orchestrator_callable(self, wrap) -> Callable:
65+
"""Obtain decorator to construct an Orchestrator class from a user-defined Function.
66+
67+
In the old programming model, this decorator's logic was unavoidable boilerplate
68+
in user-code. Now, this is handled internally by the framework.
69+
70+
Parameters
71+
----------
72+
wrap: Callable
73+
The next decorator to be applied.
74+
75+
Returns
76+
-------
77+
Callable
78+
The function to construct an Orchestrator class from the user-defined Function,
79+
wrapped by the next decorator in the sequence.
80+
"""
81+
def decorator(orchestrator_func):
82+
# Construct an orchestrator based on the end-user code
83+
handle = Orchestrator.create(orchestrator_func)
84+
85+
# invoke next decorator, with the Orchestrator as input
86+
handle.__name__ = orchestrator_func.__name__
87+
return wrap(handle)
88+
89+
return decorator
90+
91+
def orchestration_trigger(self, context_name: str,
92+
orchestration: Optional[str] = None):
93+
"""Register an Orchestrator Function.
94+
95+
Parameters
96+
----------
97+
context_name: str
98+
Parameter name of the DurableOrchestrationContext object.
99+
orchestration: Optional[str]
100+
Name of Orchestrator Function.
101+
The value is None by default, in which case the name of the method is used.
102+
"""
103+
@self._configure_orchestrator_callable
104+
@self._configure_function_builder
105+
def wrap(fb):
106+
107+
def decorator():
108+
fb.add_trigger(
109+
trigger=OrchestrationTrigger(name=context_name,
110+
orchestration=orchestration))
111+
return fb
112+
113+
return decorator()
114+
115+
return wrap
116+
117+
def activity_trigger(self, input_name: str,
118+
activity: Optional[str] = None):
119+
"""Register an Activity Function.
120+
121+
Parameters
122+
----------
123+
input_name: str
124+
Parameter name of the Activity input.
125+
activity: Optional[str]
126+
Name of Activity Function.
127+
The value is None by default, in which case the name of the method is used.
128+
"""
129+
@self._configure_function_builder
130+
def wrap(fb):
131+
def decorator():
132+
fb.add_trigger(
133+
trigger=ActivityTrigger(name=input_name,
134+
activity=activity))
135+
return fb
136+
137+
return decorator()
138+
139+
return wrap
140+
141+
def entity_trigger(self, context_name: str,
142+
entity_name: Optional[str] = None):
143+
"""Register an Entity Function.
144+
145+
Parameters
146+
----------
147+
context_name: str
148+
Parameter name of the Entity input.
149+
entity_name: Optional[str]
150+
Name of Entity Function.
151+
The value is None by default, in which case the name of the method is used.
152+
"""
153+
@self._configure_entity_callable
154+
@self._configure_function_builder
155+
def wrap(fb):
156+
def decorator():
157+
fb.add_trigger(
158+
trigger=EntityTrigger(name=context_name,
159+
entity_name=entity_name))
160+
return fb
161+
162+
return decorator()
163+
164+
return wrap
165+
166+
def _add_rich_client(self, fb, parameter_name,
167+
client_constructor):
168+
# Obtain user-code and force type annotation on the client-binding parameter to be `str`.
169+
# This ensures a passing type-check of that specific parameter,
170+
# circumventing a limitation of the worker in type-checking rich DF Client objects.
171+
# TODO: Once rich-binding type checking is possible, remove the annotation change.
172+
user_code = fb._function._func
173+
user_code.__annotations__[parameter_name] = str
174+
175+
# `wraps` This ensures we re-export the same method-signature as the decorated method
176+
@wraps(user_code)
177+
async def df_client_middleware(*args, **kwargs):
178+
179+
# Obtain JSON-string currently passed as DF Client,
180+
# construct rich object from it,
181+
# and assign parameter to that rich object
182+
starter = kwargs[parameter_name]
183+
client = client_constructor(starter)
184+
kwargs[parameter_name] = client
185+
186+
# Invoke user code with rich DF Client binding
187+
return await user_code(*args, **kwargs)
188+
189+
user_code_with_rich_client = df_client_middleware
190+
fb._function._func = user_code_with_rich_client
191+
192+
def durable_client_input(self,
193+
client_name: str,
194+
task_hub: Optional[str] = None,
195+
connection_name: Optional[str] = None
196+
):
197+
"""Register a Durable-client Function.
198+
199+
Parameters
200+
----------
201+
client_name: str
202+
Parameter name of durable client.
203+
task_hub: Optional[str]
204+
Used in scenarios where multiple function apps share the same storage account
205+
but need to be isolated from each other. If not specified, the default value
206+
from host.json is used.
207+
This value must match the value used by the target orchestrator functions.
208+
connection_name: Optional[str]
209+
The name of an app setting that contains a storage account connection string.
210+
The storage account represented by this connection string must be the same one
211+
used by the target orchestrator functions. If not specified, the default storage
212+
account connection string for the function app is used.
213+
"""
214+
215+
@self._configure_function_builder
216+
def wrap(fb):
217+
def decorator():
218+
self._add_rich_client(fb, client_name, DurableOrchestrationClient)
219+
220+
fb.add_binding(
221+
binding=DurableClient(name=client_name,
222+
task_hub=task_hub,
223+
connection_name=connection_name))
224+
return fb
225+
226+
return decorator()
227+
228+
return wrap
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
from typing import Optional
4+
5+
from azure.durable_functions.constants import ORCHESTRATION_TRIGGER, \
6+
ACTIVITY_TRIGGER, ENTITY_TRIGGER, DURABLE_CLIENT
7+
from azure.functions.decorators.core import Trigger, InputBinding
8+
9+
10+
class OrchestrationTrigger(Trigger):
11+
"""OrchestrationTrigger.
12+
13+
Trigger representing an Orchestration Function.
14+
"""
15+
16+
@staticmethod
17+
def get_binding_name() -> str:
18+
"""Get the name of this trigger, as a string.
19+
20+
Returns
21+
-------
22+
str
23+
The string representation of this trigger.
24+
"""
25+
return ORCHESTRATION_TRIGGER
26+
27+
def __init__(self,
28+
name: str,
29+
orchestration: Optional[str] = None,
30+
) -> None:
31+
self.orchestration = orchestration
32+
super().__init__(name=name)
33+
34+
35+
class ActivityTrigger(Trigger):
36+
"""ActivityTrigger.
37+
38+
Trigger representing a Durable Functions Activity.
39+
"""
40+
41+
@staticmethod
42+
def get_binding_name() -> str:
43+
"""Get the name of this trigger, as a string.
44+
45+
Returns
46+
-------
47+
str
48+
The string representation of this trigger.
49+
"""
50+
return ACTIVITY_TRIGGER
51+
52+
def __init__(self,
53+
name: str,
54+
activity: Optional[str] = None,
55+
) -> None:
56+
self.activity = activity
57+
super().__init__(name=name)
58+
59+
60+
class EntityTrigger(Trigger):
61+
"""EntityTrigger.
62+
63+
Trigger representing an Entity Function.
64+
"""
65+
66+
@staticmethod
67+
def get_binding_name() -> str:
68+
"""Get the name of this trigger, as a string.
69+
70+
Returns
71+
-------
72+
str
73+
The string representation of this trigger.
74+
"""
75+
return ENTITY_TRIGGER
76+
77+
def __init__(self,
78+
name: str,
79+
entity_name: Optional[str] = None,
80+
) -> None:
81+
self.entity_name = entity_name
82+
super().__init__(name=name)
83+
84+
85+
class DurableClient(InputBinding):
86+
"""DurableClient.
87+
88+
Binding representing a Durable-client object.
89+
"""
90+
91+
@staticmethod
92+
def get_binding_name() -> str:
93+
"""Get the name of this Binding, as a string.
94+
95+
Returns
96+
-------
97+
str
98+
The string representation of this binding.
99+
"""
100+
return DURABLE_CLIENT
101+
102+
def __init__(self,
103+
name: str,
104+
task_hub: Optional[str] = None,
105+
connection_name: Optional[str] = None
106+
) -> None:
107+
self.task_hub = task_hub
108+
self.connection_name = connection_name
109+
super().__init__(name=name)

azure/durable_functions/models/DurableOrchestrationClient.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ def get_client_response_links(
203203
payload = self._orchestration_bindings.management_urls.copy()
204204

205205
for key, _ in payload.items():
206-
if not(request is None) and request.url:
206+
if not (request is None) and request.url:
207207
payload[key] = self._replace_url_origin(request.url, payload[key])
208208
payload[key] = payload[key].replace(
209209
self._orchestration_bindings.management_urls["id"], instance_id)

azure/durable_functions/models/DurableOrchestrationContext.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,7 @@ def _generate_task(self, action: Action,
129129
task.parent = parent
130130

131131
# if task is retryable, provide the retryable wrapper class
132-
if not(retry_options is None):
132+
if not (retry_options is None):
133133
task = RetryAbleTask(task, retry_options, self)
134134
return task
135135

@@ -505,7 +505,7 @@ def will_continue_as_new(self) -> bool:
505505
return self._continue_as_new_flag
506506

507507
def create_timer(self, fire_at: datetime.datetime) -> TaskBase:
508-
"""Create a Durable Timer Task to implement a deadline at which to wake-up the orchestrator.
508+
"""Create a Timer Task to fire after at the specified deadline.
509509
510510
Parameters
511511
----------

0 commit comments

Comments
 (0)