diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index f9f6df4..c8ba963 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -15,6 +15,7 @@ CosmosDBChangeFeedMode, PromptArgument) from .decorators.mcp import mcp_content from ._durable_functions import OrchestrationContext, EntityContext +from .durable_functions import register_durable_converters from .decorators.function_app import (FunctionRegister, TriggerApi, BindingApi, SettingsApi) from .extension import (ExtensionMeta, FunctionExtensionException, @@ -48,6 +49,23 @@ from . import connectors # NoQA +# Register Durable Functions converters lazily, and only when a Durable +# binding is actually looked up. Registering at import time would trigger +# importing the Durable Functions SDK (which imports azure.functions at its +# top level) while azure.functions is still initializing -- a re-entrant +# import. Scoping the callback to the Durable binding names also keeps the +# SDK import off the code path of non-Durable apps entirely: they never look +# up these bindings, so the import is never attempted. +_DURABLE_BINDING_NAMES = ( + "orchestrationTrigger", + "entityTrigger", + "activityTrigger", + "durableClient", +) +get_binding_registry().register_deferred( + register_durable_converters, binding_names=_DURABLE_BINDING_NAMES) + + __all__ = ( # Functions 'get_binding_registry', diff --git a/azure/functions/decorators/durable_functions.py b/azure/functions/decorators/durable_functions.py new file mode 100644 index 0000000..0274bb2 --- /dev/null +++ b/azure/functions/decorators/durable_functions.py @@ -0,0 +1,39 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import logging + +_logger = logging.getLogger('azure.functions.DurableFunctions') + +df = None + + +def get_durable_package(): + """Determines which Durable SDK is being used. + + If the `azure-functions-durable` package is installed, we + log a warning that this legacy package + is deprecated. + + If both the legacy and current packages are installed, + we log a warning and prefer the current package. + + If neither package is installed, we return None. + """ + global df + if df: + return df + + try: + import azure.durable_functions as durable_functions # noqa + except ImportError: + _logger.debug("`azure.durable_functions` package not found.") + return None + + if hasattr(durable_functions, 'version') and durable_functions.version.startswith("2."): + _logger.debug("Using `azure.durable_functions` v2.x package.") + else: + _logger.debug("Using `azure.durable_functions` v1.x package.") + + df = durable_functions + + return df diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 3dd33e7..e191092 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -25,6 +25,7 @@ DaprBindingTrigger, DaprInvokeOutput, DaprPublishOutput, \ DaprSecretInput, DaprServiceInvocationTrigger, DaprStateInput, \ DaprStateOutput, DaprTopicTrigger +from azure.functions.decorators.durable_functions import get_durable_package from azure.functions.decorators.eventgrid import EventGridTrigger, \ EventGridOutput from azure.functions.decorators.eventhub import EventHubTrigger, EventHubOutput @@ -352,11 +353,13 @@ def _get_durable_blueprint(self): """Attempt to import the Durable Functions SDK from which DF decorators are implemented. """ - try: - import azure.durable_functions as df + logger = logging.getLogger('azure.functions.DurableFunctions') + logger.debug("Getting Durable Functions blueprint.") + df = get_durable_package() + if df: df_bp = df.Blueprint() return df_bp - except ImportError: + else: error_message = \ "Attempted to use a Durable Functions decorator, " \ "but the `azure-functions-durable` SDK package could not be " \ diff --git a/azure/functions/durable_functions.py b/azure/functions/durable_functions.py index b7ac47e..70a6328 100644 --- a/azure/functions/durable_functions.py +++ b/azure/functions/durable_functions.py @@ -1,18 +1,23 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -import typing import json +import logging +import typing from azure.functions import _durable_functions +from azure.functions.decorators.durable_functions import get_durable_package from . import meta +_logger = logging.getLogger('azure.functions.DurableFunctions') -# Durable Function Orchestration Trigger -class OrchestrationTriggerConverter(meta.InConverter, - meta.OutConverter, - binding='orchestrationTrigger', - trigger=True): + +# ---------------- Legacy Durable Functions Converters ---------------- # +# Legacy Durable Function Orchestration Trigger +class LegacyOrchestrationTriggerConverter(meta.InConverter, + meta.OutConverter, + binding=None, + trigger=True): @classmethod def check_input_type_annotation(cls, pytype): return issubclass(pytype, _durable_functions.OrchestrationContext) @@ -39,10 +44,11 @@ def has_implicit_output(cls) -> bool: return True -class EnitityTriggerConverter(meta.InConverter, - meta.OutConverter, - binding='entityTrigger', - trigger=True): +# Legacy Durable Function Entity Trigger +class LegacyEnitityTriggerConverter(meta.InConverter, + meta.OutConverter, + binding=None, + trigger=True): @classmethod def check_input_type_annotation(cls, pytype): return issubclass(pytype, _durable_functions.EntityContext) @@ -69,11 +75,11 @@ def has_implicit_output(cls) -> bool: return True -# Durable Function Activity Trigger -class ActivityTriggerConverter(meta.InConverter, - meta.OutConverter, - binding='activityTrigger', - trigger=True): +# Legacy Durable Function Activity Trigger +class LegacyActivityTriggerConverter(meta.InConverter, + meta.OutConverter, + binding=None, + trigger=True): @classmethod def check_input_type_annotation(cls, pytype): # Activity Trigger's arguments should accept any types @@ -135,10 +141,10 @@ def has_implicit_output(cls) -> bool: return True -# Durable Functions Durable Client Bindings -class DurableClientConverter(meta.InConverter, - meta.OutConverter, - binding='durableClient'): +# Legacy Durable Functions Durable Client Bindings +class LegacyDurableClientConverter(meta.InConverter, + meta.OutConverter, + binding=None): @classmethod def has_implicit_output(cls) -> bool: return False @@ -199,3 +205,201 @@ def decode(cls, data: meta.Datum, *, trigger_metadata) -> typing.Any: ) return result + + +# ---------------- Durable Task Durable Functions Converters ---------------- # +# Durable Function Orchestration Trigger +class OrchestrationTriggerConverter(meta.InConverter, + meta.OutConverter, + binding=None, + trigger=True): + @classmethod + def check_input_type_annotation(cls, pytype): + return issubclass(pytype, _durable_functions.OrchestrationContext) + + @classmethod + def check_output_type_annotation(cls, pytype): + # Implicit output should accept any return type + return True + + @classmethod + def decode(cls, + data: meta.Datum, *, + trigger_metadata) -> _durable_functions.OrchestrationContext: + return _durable_functions.OrchestrationContext(data.value) + + @classmethod + def encode(cls, obj: typing.Any, *, + expected_type: typing.Optional[type]) -> meta.Datum: + # Durable function context should be a string + return meta.Datum(type='string', value=obj) + + @classmethod + def has_implicit_output(cls) -> bool: + return True + + +# Durable Function Entity Trigger +class EnitityTriggerConverter(meta.InConverter, + meta.OutConverter, + binding=None, + trigger=True): + @classmethod + def check_input_type_annotation(cls, pytype): + return issubclass(pytype, _durable_functions.EntityContext) + + @classmethod + def check_output_type_annotation(cls, pytype): + # Implicit output should accept any return type + return True + + @classmethod + def decode(cls, + data: meta.Datum, *, + trigger_metadata) -> _durable_functions.EntityContext: + return _durable_functions.EntityContext(data.value) + + @classmethod + def encode(cls, obj: typing.Any, *, + expected_type: typing.Optional[type]) -> meta.Datum: + # Durable function context should be a string + return meta.Datum(type='string', value=obj) + + @classmethod + def has_implicit_output(cls) -> bool: + return True + + +# Durable Function Activity Trigger +class ActivityTriggerConverter(meta.InConverter, + meta.OutConverter, + binding=None, + trigger=True): + @classmethod + def check_input_type_annotation(cls, pytype): + # Activity Trigger's arguments should accept any types + return True + + @classmethod + def check_output_type_annotation(cls, pytype): + # The activity trigger should accept any JSON serializable types + return True + + @classmethod + def decode(cls, + data: meta.Datum, *, + trigger_metadata) -> typing.Any: + data_type = data.type + + # Durable functions extension always returns a string of json + # See durable functions library's call_activity_task docs + if data_type in ['string', 'json']: + try: + callback = _durable_functions._deserialize_custom_object + result = json.loads(data.value, object_hook=callback) + except json.JSONDecodeError: + # String failover if the content is not json serializable + result = data.value + except Exception as e: + raise ValueError( + 'activity trigger input must be a string or a ' + f'valid json serializable ({data.value})') from e + else: + raise NotImplementedError( + f'unsupported activity trigger payload type: {data_type}') + + return result + + @classmethod + def encode(cls, obj: typing.Any, *, + expected_type: typing.Optional[type]) -> meta.Datum: + try: + callback = _durable_functions._serialize_custom_object + result = json.dumps(obj, default=callback) + except TypeError as e: + raise ValueError( + f'activity trigger output must be json serializable ({obj})') from e + + return meta.Datum(type='json', value=result) + + @classmethod + def has_implicit_output(cls) -> bool: + return True + + +# Durable Functions Durable Client Bindings +class DurableClientConverter(meta.InConverter, + meta.OutConverter, + binding=None): + @classmethod + def has_implicit_output(cls) -> bool: + return False + + @classmethod + def has_trigger_support(cls) -> bool: + return False + + @classmethod + def check_input_type_annotation(cls, pytype: type) -> bool: + adf = get_durable_package() + return issubclass(pytype, (str, bytes, adf.DurableFunctionsClient)) + + @classmethod + def check_output_type_annotation(cls, pytype: type) -> bool: + return issubclass(pytype, (str, bytes, bytearray)) + + @classmethod + def encode(cls, obj: typing.Any, *, + expected_type: typing.Optional[type]) -> meta.Datum: + if isinstance(obj, str): + return meta.Datum(type='string', value=obj) + + elif isinstance(obj, (bytes, bytearray)): + return meta.Datum(type='bytes', value=bytes(obj)) + elif obj is None: + return meta.Datum(type=None, value=obj) + elif isinstance(obj, dict): + return meta.Datum(type='dict', value=obj) + elif isinstance(obj, list): + return meta.Datum(type='list', value=obj) + elif isinstance(obj, bool): + return meta.Datum(type='bool', value=obj) + elif isinstance(obj, int): + return meta.Datum(type='int', value=obj) + elif isinstance(obj, float): + return meta.Datum(type='double', value=obj) + else: + raise NotImplementedError + + @classmethod + def decode(cls, data: meta.Datum, *, trigger_metadata) -> typing.Any: + adf = get_durable_package() + return adf.DurableFunctionsClient(data.value) + + +def register_durable_converters(): + """ + Registers the appropriate Durable Functions converters based on the + installed Durable Functions package. + """ + pkg = get_durable_package() + if pkg is None: + return + + meta._ConverterMeta._bindings.pop("orchestrationTrigger", None) + meta._ConverterMeta._bindings.pop("entityTrigger", None) + meta._ConverterMeta._bindings.pop("activityTrigger", None) + meta._ConverterMeta._bindings.pop("durableClient", None) + + if hasattr(pkg, 'version') and pkg.version.startswith("2."): + _logger.debug("Registering Durable Task Durable Functions converters.") + meta._ConverterMeta._bindings["orchestrationTrigger"] = OrchestrationTriggerConverter + meta._ConverterMeta._bindings["entityTrigger"] = EnitityTriggerConverter + meta._ConverterMeta._bindings["activityTrigger"] = ActivityTriggerConverter + meta._ConverterMeta._bindings["durableClient"] = DurableClientConverter + else: + _logger.debug("Registering Legacy Durable Functions converters.") + meta._ConverterMeta._bindings["orchestrationTrigger"] = LegacyOrchestrationTriggerConverter + meta._ConverterMeta._bindings["entityTrigger"] = LegacyEnitityTriggerConverter + meta._ConverterMeta._bindings["activityTrigger"] = LegacyActivityTriggerConverter + meta._ConverterMeta._bindings["durableClient"] = LegacyDurableClientConverter diff --git a/azure/functions/meta.py b/azure/functions/meta.py index 09314f8..8afdf07 100644 --- a/azure/functions/meta.py +++ b/azure/functions/meta.py @@ -5,7 +5,8 @@ import collections.abc import datetime import re -from typing import Dict, Optional, Union, Tuple, Mapping, Any +from typing import (Any, Callable, Dict, Iterable, List, Mapping, Optional, + Tuple, Union) from ._jsonutils import json from ._thirdparty import typing_inspect @@ -84,6 +85,8 @@ def __repr__(self): class _ConverterMeta(abc.ABCMeta): _bindings: Dict[str, type] = {} + _deferred_registrations: List[ + Tuple[Callable[[], None], Optional[frozenset]]] = [] def __new__(mcls, name, bases, dct, *, binding: Optional[str], @@ -105,8 +108,47 @@ def __new__(mcls, name, bases, dct, *, return cls + @classmethod + def register_deferred( + cls, + callback: Callable[[], None], + binding_names: Optional[Iterable[str]] = None) -> None: + """Register a callback that lazily registers converters on binding + lookup. + + This lets optional dependencies (e.g. the Durable Functions SDK, + which imports ``azure.functions`` at its top level) be imported only + after ``azure.functions`` has finished initializing, avoiding a + re-entrant import at ``azure.functions`` import time. + + If ``binding_names`` is provided, the callback only runs when one of + those bindings is actually looked up. This keeps the callback (and + any import it performs) off the code path of apps that never use + those bindings -- e.g. a non-Durable app never triggers the Durable + Functions SDK import. When ``binding_names`` is None the callback + runs on the first binding lookup of any kind. + """ + names = frozenset(binding_names) if binding_names is not None else None + cls._deferred_registrations.append((callback, names)) + + @classmethod + def _run_deferred_registrations(cls, binding_name): + if not cls._deferred_registrations: + return + remaining = [] + fired = [] + for callback, names in cls._deferred_registrations: + if names is None or binding_name in names: + fired.append(callback) + else: + remaining.append((callback, names)) + cls._deferred_registrations = remaining + for callback in fired: + callback() + @classmethod def get(cls, binding_name): + cls._run_deferred_registrations(binding_name) return cls._bindings.get(binding_name) def has_trigger_support(cls) -> bool: diff --git a/tests/test_durable_functions.py b/tests/test_durable_functions.py index 043b373..bcf1df7 100644 --- a/tests/test_durable_functions.py +++ b/tests/test_durable_functions.py @@ -1,23 +1,32 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import sys import unittest import json +from unittest import mock from azure.functions.durable_functions import ( + LegacyOrchestrationTriggerConverter, + LegacyEnitityTriggerConverter, + LegacyActivityTriggerConverter, + LegacyDurableClientConverter, OrchestrationTriggerConverter, EnitityTriggerConverter, ActivityTriggerConverter, - DurableClientConverter + DurableClientConverter, + register_durable_converters, ) +from azure.functions.decorators import durable_functions as df_decorators from azure.functions._durable_functions import ( OrchestrationContext, EntityContext ) +from azure.functions import meta from azure.functions.meta import Datum CONTEXT_CLASSES = [OrchestrationContext, EntityContext] -CONVERTERS = [OrchestrationTriggerConverter, EnitityTriggerConverter] +CONVERTERS = [LegacyOrchestrationTriggerConverter, LegacyEnitityTriggerConverter] class TestDurableFunctions(unittest.TestCase): @@ -127,7 +136,7 @@ def test_activity_trigger_inputs(self): ] for datum in data: - decoded = ActivityTriggerConverter.decode( + decoded = LegacyActivityTriggerConverter.decode( data=datum['input'], trigger_metadata=None) self.assertEqual(decoded, datum['expected_value']) @@ -160,7 +169,7 @@ def test_activity_trigger_encode(self): ] for datum in data: - encoded = ActivityTriggerConverter.encode( + encoded = LegacyActivityTriggerConverter.encode( obj=datum['output'], expected_type=type(datum['output'])) self.assertEqual(encoded, datum['expected_value']) @@ -173,7 +182,7 @@ def __init__(self): data = NonEncodable() try: - ActivityTriggerConverter.encode(data, expected_type=None) + LegacyActivityTriggerConverter.encode(data, expected_type=None) except ValueError as e: self.assertIsNotNone(e.__cause__) self.assertIsInstance(e.__cause__, TypeError) @@ -217,7 +226,7 @@ def test_activity_trigger_decode(self): ] for datum in data: - decoded = ActivityTriggerConverter.decode( + decoded = LegacyActivityTriggerConverter.decode( data=datum['input'], trigger_metadata=None) self.assertEqual(decoded, datum['expected_value']) @@ -226,7 +235,7 @@ def test_activity_trigger_decode_failure_exception_has_cause(self): data = Datum('{"value": "bar"}', 'json') try: - ActivityTriggerConverter.decode( + LegacyActivityTriggerConverter.decode( data=data, trigger_metadata=None) except ValueError as e: @@ -235,17 +244,17 @@ def test_activity_trigger_decode_failure_exception_has_cause(self): def test_activity_trigger_has_implicit_return(self): self.assertTrue( - ActivityTriggerConverter.has_implicit_output() + LegacyActivityTriggerConverter.has_implicit_output() ) def test_durable_client_no_implicit_return(self): self.assertFalse( - DurableClientConverter.has_implicit_output() + LegacyDurableClientConverter.has_implicit_output() ) def test_enitity_trigger_check_output_type_annotation(self): self.assertTrue( - EnitityTriggerConverter.check_output_type_annotation(pytype=None) + LegacyEnitityTriggerConverter.check_output_type_annotation(pytype=None) ) def test_activity_trigger_converter_decode_no_implementation_exception( @@ -254,7 +263,7 @@ def test_activity_trigger_converter_decode_no_implementation_exception( datum = Datum(value=b"dummy", type="bytes") # when try: - ActivityTriggerConverter.decode(data=datum, trigger_metadata=None) + LegacyActivityTriggerConverter.decode(data=datum, trigger_metadata=None) except NotImplementedError: is_exception_raised = True @@ -265,82 +274,355 @@ def test_enitity_trigger_converter_encode(self): data = '{"dummy_key": "dummy_value"}' - result = EnitityTriggerConverter.encode( + result = LegacyEnitityTriggerConverter.encode( obj=data, expected_type=None) self.assertEqual(result.type, "json") self.assertEqual(result.python_value, {'dummy_key': 'dummy_value'}) def test_durable_client_converter_has_trigger_support(self): - self.assertFalse(DurableClientConverter.has_trigger_support()) + self.assertFalse(LegacyDurableClientConverter.has_trigger_support()) def test_durable_client_converter_check_input_type_annotation(self): - self.assertTrue(DurableClientConverter.check_input_type_annotation(str)) - self.assertTrue(DurableClientConverter.check_input_type_annotation(bytes)) - self.assertFalse(DurableClientConverter.check_input_type_annotation(int)) + self.assertTrue(LegacyDurableClientConverter.check_input_type_annotation(str)) + self.assertTrue(LegacyDurableClientConverter.check_input_type_annotation(bytes)) + self.assertFalse(LegacyDurableClientConverter.check_input_type_annotation(int)) def test_durable_client_converter_check_output_type_annotation(self): - self.assertTrue(DurableClientConverter.check_output_type_annotation(str)) - self.assertTrue(DurableClientConverter.check_output_type_annotation(bytes)) - self.assertTrue(DurableClientConverter.check_output_type_annotation(bytearray)) - self.assertFalse(DurableClientConverter.check_output_type_annotation(int)) + self.assertTrue(LegacyDurableClientConverter.check_output_type_annotation(str)) + self.assertTrue(LegacyDurableClientConverter.check_output_type_annotation(bytes)) + self.assertTrue(LegacyDurableClientConverter.check_output_type_annotation(bytearray)) + self.assertFalse(LegacyDurableClientConverter.check_output_type_annotation(int)) def test_durable_client_converter_encode(self): - datum = DurableClientConverter.encode(obj="hello", expected_type=str) + datum = LegacyDurableClientConverter.encode(obj="hello", expected_type=str) self.assertEqual(datum.type, "string") self.assertEqual(datum.value, "hello") - datum = DurableClientConverter.encode(obj=b"data", expected_type=bytes) + datum = LegacyDurableClientConverter.encode(obj=b"data", expected_type=bytes) self.assertEqual(datum.type, "bytes") self.assertEqual(datum.value, b"data") - datum = DurableClientConverter.encode(obj=None, expected_type=None) + datum = LegacyDurableClientConverter.encode(obj=None, expected_type=None) self.assertIsNone(datum.type) self.assertIsNone(datum.value) - datum = DurableClientConverter.encode(obj={"a": 1}, expected_type=dict) + datum = LegacyDurableClientConverter.encode(obj={"a": 1}, expected_type=dict) self.assertEqual(datum.type, "dict") self.assertEqual(datum.value, {"a": 1}) - datum = DurableClientConverter.encode(obj=[1, 2], expected_type=list) + datum = LegacyDurableClientConverter.encode(obj=[1, 2], expected_type=list) self.assertEqual(datum.type, "list") self.assertEqual(datum.value, [1, 2]) - datum = DurableClientConverter.encode(obj=42, expected_type=int) + datum = LegacyDurableClientConverter.encode(obj=42, expected_type=int) self.assertEqual(datum.type, "int") self.assertEqual(datum.value, 42) - datum = DurableClientConverter.encode(obj=3.14, expected_type=float) + datum = LegacyDurableClientConverter.encode(obj=3.14, expected_type=float) self.assertEqual(datum.type, "double") self.assertEqual(datum.value, 3.14) - datum = DurableClientConverter.encode(obj=True, expected_type=bool) + datum = LegacyDurableClientConverter.encode(obj=True, expected_type=bool) self.assertEqual(datum.type, "bool") self.assertTrue(datum.value) with self.assertRaises(NotImplementedError): - DurableClientConverter.encode(obj=set([1, 2]), expected_type=set) + LegacyDurableClientConverter.encode(obj=set([1, 2]), expected_type=set) def test_durable_client_converter_decode(self): data = Datum(type="string", value="abc") - result = DurableClientConverter.decode(data=data, trigger_metadata=None) + result = LegacyDurableClientConverter.decode(data=data, trigger_metadata=None) self.assertEqual(result, "abc") data = Datum(type="bytes", value=b"123") - result = DurableClientConverter.decode(data=data, trigger_metadata=None) + result = LegacyDurableClientConverter.decode(data=data, trigger_metadata=None) self.assertEqual(result, b"123") data = Datum(type="json", value={"key": "val"}) - result = DurableClientConverter.decode(data=data, trigger_metadata=None) + result = LegacyDurableClientConverter.decode(data=data, trigger_metadata=None) self.assertEqual(result, {"key": "val"}) data = Datum(type=None, value=None) - result = DurableClientConverter.decode(data=data, trigger_metadata=None) + result = LegacyDurableClientConverter.decode(data=data, trigger_metadata=None) self.assertIsNone(result) - result = DurableClientConverter.decode(data=None, trigger_metadata=None) + result = LegacyDurableClientConverter.decode(data=None, trigger_metadata=None) self.assertIsNone(result) data = Datum(type="weird", value="???") with self.assertRaises(ValueError): - DurableClientConverter.decode(data=data, trigger_metadata=None) + LegacyDurableClientConverter.decode(data=data, trigger_metadata=None) + + +V2_CONTEXT_CLASSES = [OrchestrationContext, EntityContext] +V2_TRIGGER_CONVERTERS = [OrchestrationTriggerConverter, EnitityTriggerConverter] + + +class TestDurableFunctionsV2Converters(unittest.TestCase): + """Tests for the Durable Task (v2.x) Durable Functions converters.""" + + def test_trigger_converter_decode(self): + datum = Datum(value='{ "name": "great function" }', type=str) + for converter in V2_TRIGGER_CONVERTERS: + ctx = converter.decode(datum, trigger_metadata=None) + content = json.loads(ctx.body) + self.assertEqual(content.get('name'), 'great function') + + def test_trigger_converter_encode_uses_string(self): + # The v2 converters encode the context as a string, unlike the + # legacy converters which encode as json. + for converter in V2_TRIGGER_CONVERTERS: + datum = converter.encode('some-context', expected_type=None) + self.assertEqual(datum.type, 'string') + self.assertEqual(datum.value, 'some-context') + + def test_trigger_check_good_annotation(self): + for converter, ctx in zip(V2_TRIGGER_CONVERTERS, V2_CONTEXT_CLASSES): + self.assertTrue(converter.check_input_type_annotation(ctx)) + + def test_trigger_check_bad_annotation(self): + for dt in (str, bytes, int): + for converter in V2_TRIGGER_CONVERTERS: + self.assertFalse(converter.check_input_type_annotation(dt)) + + def test_trigger_check_output_type_annotation(self): + for converter in V2_TRIGGER_CONVERTERS: + self.assertTrue(converter.check_output_type_annotation(pytype=None)) + + def test_trigger_has_implicit_return(self): + for converter in V2_TRIGGER_CONVERTERS: + self.assertTrue(converter.has_implicit_output()) + + def test_activity_trigger_decode(self): + data = [ + { + 'input': Datum('sample', 'string'), + 'expected_value': 'sample', + 'expected_type': str + }, + { + 'input': Datum('123', 'string'), + 'expected_value': 123, + 'expected_type': int + }, + { + 'input': Datum('1234.56', 'string'), + 'expected_value': 1234.56, + 'expected_type': float + }, + { + 'input': Datum('[ "do", "re", "mi" ]', 'string'), + 'expected_value': ["do", "re", "mi"], + 'expected_type': list + }, + { + 'input': Datum('{ "number": "42" }', 'string'), + 'expected_value': {"number": "42"}, + 'expected_type': dict + } + ] + + for datum in data: + decoded = ActivityTriggerConverter.decode( + data=datum['input'], + trigger_metadata=None) + self.assertEqual(decoded, datum['expected_value']) + self.assertEqual(type(decoded), datum['expected_type']) + + def test_activity_trigger_decode_string_failover(self): + # Non-json serializable strings fail over to the raw string value. + datum = Datum('sample_string', 'string') + decoded = ActivityTriggerConverter.decode( + data=datum, trigger_metadata=None) + self.assertEqual(decoded, 'sample_string') + + def test_activity_trigger_encode(self): + data = [ + { + 'output': str('sample'), + 'expected_value': Datum('"sample"', 'json'), + }, + { + 'output': int(123), + 'expected_value': Datum('123', 'json'), + }, + { + 'output': list(["do", "re", "mi"]), + 'expected_value': Datum('["do", "re", "mi"]', 'json') + }, + ] + + for datum in data: + encoded = ActivityTriggerConverter.encode( + obj=datum['output'], + expected_type=type(datum['output'])) + self.assertEqual(encoded, datum['expected_value']) + + def test_activity_trigger_encode_failure_exception_has_cause(self): + class NonEncodable: + def __init__(self): + self.value = 'foo' + + try: + ActivityTriggerConverter.encode(NonEncodable(), expected_type=None) + except ValueError as e: + self.assertIsNotNone(e.__cause__) + self.assertIsInstance(e.__cause__, TypeError) + + def test_activity_trigger_decode_no_implementation_exception(self): + datum = Datum(value=b"dummy", type="bytes") + with self.assertRaises(NotImplementedError): + ActivityTriggerConverter.decode(data=datum, trigger_metadata=None) + + def test_activity_trigger_has_implicit_return(self): + self.assertTrue(ActivityTriggerConverter.has_implicit_output()) + + def test_durable_client_no_implicit_return(self): + self.assertFalse(DurableClientConverter.has_implicit_output()) + + def test_durable_client_converter_has_trigger_support(self): + self.assertFalse(DurableClientConverter.has_trigger_support()) + + def test_durable_client_converter_check_output_type_annotation(self): + self.assertTrue( + DurableClientConverter.check_output_type_annotation(str)) + self.assertTrue( + DurableClientConverter.check_output_type_annotation(bytes)) + self.assertTrue( + DurableClientConverter.check_output_type_annotation(bytearray)) + self.assertFalse( + DurableClientConverter.check_output_type_annotation(int)) + + def test_durable_client_converter_encode(self): + datum = DurableClientConverter.encode(obj="hello", expected_type=str) + self.assertEqual(datum.type, "string") + self.assertEqual(datum.value, "hello") + + datum = DurableClientConverter.encode(obj=b"data", expected_type=bytes) + self.assertEqual(datum.type, "bytes") + self.assertEqual(datum.value, b"data") + + datum = DurableClientConverter.encode(obj=None, expected_type=None) + self.assertIsNone(datum.type) + + with self.assertRaises(NotImplementedError): + DurableClientConverter.encode(obj=set([1, 2]), expected_type=set) + + def test_durable_client_converter_check_input_type_annotation(self): + class FakeDurableFunctionsClient: + pass + + fake_adf = mock.MagicMock() + fake_adf.DurableFunctionsClient = FakeDurableFunctionsClient + + with mock.patch.object( + sys.modules['azure.functions.durable_functions'], + 'get_durable_package', return_value=fake_adf): + self.assertTrue( + DurableClientConverter.check_input_type_annotation(str)) + self.assertTrue( + DurableClientConverter.check_input_type_annotation(bytes)) + self.assertTrue( + DurableClientConverter.check_input_type_annotation( + FakeDurableFunctionsClient)) + self.assertFalse( + DurableClientConverter.check_input_type_annotation(int)) + + def test_durable_client_converter_decode(self): + class FakeDurableFunctionsClient: + def __init__(self, value): + self.value = value + + fake_adf = mock.MagicMock() + fake_adf.DurableFunctionsClient = FakeDurableFunctionsClient + + with mock.patch.object( + sys.modules['azure.functions.durable_functions'], + 'get_durable_package', return_value=fake_adf): + data = Datum(type="string", value="instance-id") + result = DurableClientConverter.decode( + data=data, trigger_metadata=None) + self.assertIsInstance(result, FakeDurableFunctionsClient) + self.assertEqual(result.value, "instance-id") + + +class TestRegisterDurableConverters(unittest.TestCase): + """Tests for register_durable_converters and get_durable_package.""" + + def setUp(self): + # Preserve and restore the global binding registry so tests do not + # leak state between one another. + self._original_bindings = dict(meta._ConverterMeta._bindings) + # Reset the cached durable package before each test. + df_decorators.df = None + + def tearDown(self): + meta._ConverterMeta._bindings.clear() + meta._ConverterMeta._bindings.update(self._original_bindings) + df_decorators.df = None + + def test_register_noop_when_package_missing(self): + before = dict(meta._ConverterMeta._bindings) + with mock.patch.object( + sys.modules['azure.functions.durable_functions'], + 'get_durable_package', return_value=None): + register_durable_converters() + + self.assertEqual(meta._ConverterMeta._bindings, before) + + def test_register_legacy_converters(self): + legacy_pkg = mock.MagicMock(spec=['__name__']) + legacy_pkg.__name__ = 'azure.durable_functions' + + with mock.patch.object( + sys.modules['azure.functions.durable_functions'], + 'get_durable_package', return_value=legacy_pkg): + register_durable_converters() + + bindings = meta._ConverterMeta._bindings + self.assertIs( + bindings["orchestrationTrigger"], + LegacyOrchestrationTriggerConverter) + self.assertIs( + bindings["entityTrigger"], LegacyEnitityTriggerConverter) + self.assertIs( + bindings["activityTrigger"], LegacyActivityTriggerConverter) + self.assertIs( + bindings["durableClient"], LegacyDurableClientConverter) + + def test_register_v2_converters(self): + v2_pkg = mock.MagicMock(spec=['__name__', 'version']) + v2_pkg.__name__ = 'azure.durable_functions' + v2_pkg.version = '2.0.0' + + with mock.patch.object( + sys.modules['azure.functions.durable_functions'], + 'get_durable_package', return_value=v2_pkg): + register_durable_converters() + + bindings = meta._ConverterMeta._bindings + self.assertIs( + bindings["orchestrationTrigger"], OrchestrationTriggerConverter) + self.assertIs(bindings["entityTrigger"], EnitityTriggerConverter) + self.assertIs(bindings["activityTrigger"], ActivityTriggerConverter) + self.assertIs(bindings["durableClient"], DurableClientConverter) + + def test_get_durable_package_not_installed(self): + with mock.patch.dict(sys.modules, {'azure.durable_functions': None}): + df_decorators.df = None + self.assertIsNone(df_decorators.get_durable_package()) + + def test_get_durable_package_v2_detection(self): + import azure.durable_functions as adf + df_decorators.df = None + with mock.patch.object(adf, 'version', '2.5.0', create=True): + self.assertIs(df_decorators.get_durable_package(), adf) + + def test_get_durable_package_caches_result(self): + import azure.durable_functions as adf + df_decorators.df = None + first = df_decorators.get_durable_package() + self.assertIs(first, adf) + # A subsequent call returns the cached reference. + self.assertIs(df_decorators.get_durable_package(), first) diff --git a/tests/test_meta.py b/tests/test_meta.py index c13657a..dff9f6b 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -254,3 +254,55 @@ def _parse_datetime(self, datetime_str): def _parse_timedelta(self, timedelta_str): return meta._BaseConverter._parse_timedelta(timedelta_str) + + +class TestDeferredRegistration(unittest.TestCase): + """Tests for the lazy converter registration hook on _ConverterMeta.""" + + def setUp(self): + registry = meta._ConverterMeta + self._saved_registrations = list(registry._deferred_registrations) + registry._deferred_registrations = [] + + def tearDown(self): + registry = meta._ConverterMeta + registry._deferred_registrations = self._saved_registrations + + def test_deferred_callback_runs_on_first_get(self): + calls = [] + meta._ConverterMeta.register_deferred(lambda: calls.append(1)) + + # Callback should not run until the first binding lookup. + self.assertEqual(calls, []) + + meta.get_binding_registry().get("someBinding") + self.assertEqual(calls, [1]) + + def test_deferred_callback_runs_only_once(self): + calls = [] + meta._ConverterMeta.register_deferred(lambda: calls.append(1)) + + meta.get_binding_registry().get("someBinding") + meta.get_binding_registry().get("anotherBinding") + self.assertEqual(calls, [1]) + + def test_scoped_callback_only_runs_for_matching_binding(self): + calls = [] + meta._ConverterMeta.register_deferred( + lambda: calls.append(1), + binding_names=("orchestrationTrigger", "activityTrigger")) + + # A lookup for an unrelated binding must not run the callback, + # keeping any import it performs off that app's code path. + meta.get_binding_registry().get("httpTrigger") + meta.get_binding_registry().get("queueTrigger") + self.assertEqual(calls, []) + + # A lookup for one of the scoped bindings runs it once. + meta.get_binding_registry().get("activityTrigger") + self.assertEqual(calls, [1]) + + # Subsequent lookups (matching or not) do not re-run it. + meta.get_binding_registry().get("orchestrationTrigger") + meta.get_binding_registry().get("httpTrigger") + self.assertEqual(calls, [1])