Skip to content

Commit d154419

Browse files
authored
Add orchestration trigger for durable functions (#48)
* Add orchestration & activity trigger for durable function Update durable triggers * Add dunder str method on OrchestrationContext * Added unittest cases * Fix activity trigger when there's no content
1 parent 10689c0 commit d154419

File tree

7 files changed

+196
-1
lines changed

7 files changed

+196
-1
lines changed

azure/functions/__init__.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from ._http_wsgi import WsgiMiddleware # NoQA
88
from ._queue import QueueMessage # NoQA
99
from ._servicebus import ServiceBusMessage # NoQA
10+
from ._durable_functions import OrchestrationContext # NoQA
1011
from .meta import get_binding_registry # NoQA
1112

1213
# Import binding implementations to register them
@@ -18,6 +19,7 @@
1819
from . import queue # NoQA
1920
from . import servicebus # NoQA
2021
from . import timer # NoQA
22+
from . import durable_functions # NoQA
2123

2224

2325
__all__ = (
@@ -35,9 +37,12 @@
3537
'EventHubEvent',
3638
'HttpRequest',
3739
'HttpResponse',
38-
'WsgiMiddleware',
3940
'InputStream',
41+
'OrchestrationContext',
4042
'QueueMessage',
4143
'ServiceBusMessage',
4244
'TimerRequest',
45+
46+
# Middlewares
47+
'WsgiMiddleware',
4348
)

azure/functions/_abc.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,3 +300,10 @@ def enqueued_time(self) -> typing.Optional[datetime.datetime]:
300300
@abc.abstractmethod
301301
def offset(self) -> typing.Optional[str]:
302302
pass
303+
304+
305+
class OrchestrationContext(abc.ABC):
306+
@property
307+
@abc.abstractmethod
308+
def body(self) -> str:
309+
pass

azure/functions/_durable_functions.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from typing import Union
2+
from . import _abc
3+
4+
5+
class OrchestrationContext(_abc.OrchestrationContext):
6+
"""A durable function orchestration context.
7+
8+
:param str body:
9+
The body of orchestration context json.
10+
"""
11+
12+
def __init__(self,
13+
body: Union[str, bytes]) -> None:
14+
if isinstance(body, str):
15+
self.__body = body
16+
if isinstance(body, bytes):
17+
self.__body = body.decode('utf-8')
18+
19+
@property
20+
def body(self) -> str:
21+
return self.__body
22+
23+
def __repr__(self):
24+
return (
25+
f'<azure.OrchestrationContext '
26+
f'body={self.body}>'
27+
)
28+
29+
def __str__(self):
30+
return self.__body

azure/functions/durable_functions.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from typing import Any
2+
from azure.functions import _durable_functions
3+
4+
from . import meta
5+
6+
7+
# Durable Function Orchestration Trigger
8+
class OrchestrationTriggerConverter(meta.InConverter,
9+
binding='orchestrationTrigger',
10+
trigger=True):
11+
@classmethod
12+
def check_input_type_annotation(cls, pytype):
13+
return issubclass(pytype, _durable_functions.OrchestrationContext)
14+
15+
@classmethod
16+
def decode(cls,
17+
data: meta.Datum, *,
18+
trigger_metadata) -> _durable_functions.OrchestrationContext:
19+
return _durable_functions.OrchestrationContext(data.value)
20+
21+
@classmethod
22+
def has_implicit_output(cls) -> bool:
23+
return True
24+
25+
26+
# Durable Function Activity Trigger
27+
class ActivityTriggerConverter(meta.InConverter,
28+
binding='activityTrigger',
29+
trigger=True):
30+
@classmethod
31+
def check_input_type_annotation(cls, pytype):
32+
# Activity Trigger's arguments should accept any types
33+
return True
34+
35+
@classmethod
36+
def decode(cls,
37+
data: meta.Datum, *,
38+
trigger_metadata) -> Any:
39+
if getattr(data, 'value', None) is not None:
40+
return data.value
41+
42+
return data
43+
44+
@classmethod
45+
def has_implicit_output(cls) -> bool:
46+
return True

azure/functions/meta.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,10 @@ def check_input_type_annotation(cls, pytype: type) -> bool:
266266
def decode(cls, data: Datum, *, trigger_metadata) -> typing.Any:
267267
raise NotImplementedError
268268

269+
@abc.abstractclassmethod
270+
def has_implicit_output(cls) -> bool:
271+
return False
272+
269273

270274
class OutConverter(_BaseConverter, binding=None):
271275

tests/test_durable_functions.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import unittest
2+
import json
3+
4+
from azure.functions.durable_functions import (
5+
OrchestrationTriggerConverter,
6+
ActivityTriggerConverter
7+
)
8+
from azure.functions._durable_functions import OrchestrationContext
9+
from azure.functions.meta import Datum
10+
11+
12+
class TestDurableFunctions(unittest.TestCase):
13+
def test_orchestration_context_string_body(self):
14+
raw_string = '{ "name": "great function" }'
15+
context = OrchestrationContext(raw_string)
16+
self.assertIsNotNone(getattr(context, 'body', None))
17+
18+
content = json.loads(context.body)
19+
self.assertEqual(content.get('name'), 'great function')
20+
21+
def test_orchestration_context_string_cast(self):
22+
raw_string = '{ "name": "great function" }'
23+
context = OrchestrationContext(raw_string)
24+
self.assertEqual(str(context), raw_string)
25+
26+
content = json.loads(str(context))
27+
self.assertEqual(content.get('name'), 'great function')
28+
29+
def test_orchestration_context_bytes_body(self):
30+
raw_bytes = '{ "name": "great function" }'.encode('utf-8')
31+
context = OrchestrationContext(raw_bytes)
32+
self.assertIsNotNone(getattr(context, 'body', None))
33+
34+
content = json.loads(context.body)
35+
self.assertEqual(content.get('name'), 'great function')
36+
37+
def test_orchestration_context_bytes_cast(self):
38+
raw_bytes = '{ "name": "great function" }'.encode('utf-8')
39+
context = OrchestrationContext(raw_bytes)
40+
self.assertIsNotNone(getattr(context, 'body', None))
41+
42+
content = json.loads(context.body)
43+
self.assertEqual(content.get('name'), 'great function')
44+
45+
def test_orchestration_trigger_converter(self):
46+
datum = Datum(value='{ "name": "great function" }',
47+
type=str)
48+
otc = OrchestrationTriggerConverter.decode(datum,
49+
trigger_metadata=None)
50+
content = json.loads(otc.body)
51+
self.assertEqual(content.get('name'), 'great function')
52+
53+
def test_orchestration_trigger_converter_type(self):
54+
datum = Datum(value='{ "name": "great function" }'.encode('utf-8'),
55+
type=bytes)
56+
otc = OrchestrationTriggerConverter.decode(datum,
57+
trigger_metadata=None)
58+
content = json.loads(otc.body)
59+
self.assertEqual(content.get('name'), 'great function')
60+
61+
def test_orchestration_trigger_check_good_annotation(self):
62+
for dt in (OrchestrationContext,):
63+
self.assertTrue(
64+
OrchestrationTriggerConverter.check_input_type_annotation(dt)
65+
)
66+
67+
def test_orchestration_trigger_check_bad_annotation(self):
68+
for dt in (str, bytes, int):
69+
self.assertFalse(
70+
OrchestrationTriggerConverter.check_input_type_annotation(dt)
71+
)
72+
73+
def test_orchestration_trigger_has_implicit_return(self):
74+
self.assertTrue(
75+
OrchestrationTriggerConverter.has_implicit_output()
76+
)
77+
78+
def test_activity_trigger_accepts_any_types(self):
79+
datum_set = {
80+
Datum('string', str),
81+
Datum(123, int),
82+
Datum(1234.56, float),
83+
Datum('string'.encode('utf-8'), bytes),
84+
Datum(Datum('{ "json": true }', str), Datum)
85+
}
86+
87+
for datum in datum_set:
88+
out = ActivityTriggerConverter.decode(datum, trigger_metadata=None)
89+
self.assertEqual(out, datum.value)
90+
self.assertEqual(type(out), datum.type)
91+
92+
def test_activity_trigger_has_implicit_return(self):
93+
self.assertTrue(
94+
ActivityTriggerConverter.has_implicit_output()
95+
)

tests/test_http.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,11 @@ def test_http_output_type(self):
7070
)
7171
self.assertTrue(check_output_type(func.HttpResponse))
7272
self.assertTrue(check_output_type(str))
73+
74+
def test_http_request_should_not_have_implicit_output(self):
75+
self.assertFalse(http.HttpRequestConverter.has_implicit_output())
76+
77+
def test_http_response_does_not_have_explicit_output(self):
78+
self.assertIsNone(
79+
getattr(http.HttpResponseConverter, 'has_implicit_output', None)
80+
)

0 commit comments

Comments
 (0)