From 5c51d8eb0ac496de71a260ad11aec759deaba42b Mon Sep 17 00:00:00 2001 From: AzureFunctionsPython Date: Mon, 1 Jun 2026 15:58:20 +0000 Subject: [PATCH 1/6] build: update Python Library Version to 2.2.0b5 --- azure/functions/__init__.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 8723de84..684e8536 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -113,4 +113,4 @@ 'mcp_content', ) -__version__ = '2.2.0b4' +__version__ = '2.2.0b5' diff --git a/pyproject.toml b/pyproject.toml index 42149240..211328c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ 'Intended Audience :: Developers', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.13', - 'Programming Language :: Python :: 3.14', +'Programming Language :: Python :: 3.14', 'Operating System :: Microsoft :: Windows', 'Operating System :: POSIX', 'Operating System :: MacOS :: MacOS X', From 6a0ad5ab26e4b505f3c563ab73338204d57c88e2 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Mon, 1 Jun 2026 11:26:28 -0500 Subject: [PATCH 2/6] Fix indentation for Python 3.14 in pyproject.toml --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 211328c1..42149240 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,7 +15,7 @@ classifiers = [ 'Intended Audience :: Developers', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3.13', -'Programming Language :: Python :: 3.14', + 'Programming Language :: Python :: 3.14', 'Operating System :: Microsoft :: Windows', 'Operating System :: POSIX', 'Operating System :: MacOS :: MacOS X', From 9d468bf31bff2a84e1d44a09c7b76c5003842944 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Tue, 30 Jun 2026 11:31:29 -0600 Subject: [PATCH 3/6] Port changes to branch supporting 2.x --- azure/functions/__init__.py | 6 +- .../functions/decorators/durable_functions.py | 43 +++ azure/functions/decorators/function_app.py | 12 +- azure/functions/durable_functions.py | 246 ++++++++++++++++-- tests/test_durable_functions.py | 76 +++--- 5 files changed, 322 insertions(+), 61 deletions(-) create mode 100644 azure/functions/decorators/durable_functions.py diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 684e8536..87b0ea4a 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -14,6 +14,7 @@ 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, @@ -46,6 +47,9 @@ from . import connectors # NoQA +register_durable_converters() + + __all__ = ( # Functions 'get_binding_registry', @@ -113,4 +117,4 @@ 'mcp_content', ) -__version__ = '2.2.0b5' +__version__ = '2.2.0b6' diff --git a/azure/functions/decorators/durable_functions.py b/azure/functions/decorators/durable_functions.py new file mode 100644 index 00000000..c2b00698 --- /dev/null +++ b/azure/functions/decorators/durable_functions.py @@ -0,0 +1,43 @@ +# 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. + """ + _logger.info("Attempting to import Durable Functions package.") + global df + if df: + _logger.info("Durable Functions package already loaded. DF: %s", df) + return df + + try: + import azure.durable_functions as durable_functions # noqa + _logger.info("`azure.durable_functions` package found.") + except ImportError: + _logger.info("`azure.durable_functions` package not found.") + return None + + try: + if hasattr(durable_functions, 'version') and durable_functions.version.startswith("2."): + _logger.info("Using `azure.durable_functions` v2.x package.") + except Exception: + _logger.info("Using `azure.durable_functions` v1.x package.") + + df = durable_functions + + return df \ No newline at end of file diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 78c3a390..4488e4fb 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 @@ -62,6 +63,8 @@ from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \ MySqlTrigger +_logger = logging.getLogger('azure.functions.DurableFunctions') + class Function(object): """ @@ -352,11 +355,12 @@ 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.info("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 " \ @@ -1726,7 +1730,7 @@ def decorator(fb: FunctionBuilder) -> FunctionBuilder: list_item_type) for union_arg in union_args: if (isinstance(union_arg, type) - and union_arg is not + and union_arg is not # noqa type(None)): if hasattr(union_arg, '__module__'): diff --git a/azure/functions/durable_functions.py b/azure/functions/durable_functions.py index b7ac47e9..b3a806c7 100644 --- a/azure/functions/durable_functions.py +++ b/azure/functions/durable_functions.py @@ -5,14 +5,19 @@ import json from azure.functions import _durable_functions +from azure.functions.decorators.durable_functions import get_durable_package from . import meta +import logging +_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,207 @@ 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: + import azure.durable_functions as adf + 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: + import azure.durable_functions as adf + return adf.DurableFunctionsClient(data.value) + + +def register_durable_converters(): + """ + Registers the appropriate Durable Functions converters based on the + installed Durable Functions package. + """ + _logger.info("Registering Durable Functions converters based on ") + pkg = get_durable_package() + if pkg is None: + return + + _logger.info("Durable Functions package loaded: %s", pkg.__name__) + _logger.info("Current bindings before registration: %s", meta._ConverterMeta._bindings) + 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 not hasattr(pkg, 'version'): + _logger.info("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 + elif hasattr(pkg, 'version') and pkg.version.startswith("2."): + _logger.info("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 + + _logger.info("Durable Functions converters registered.") + _logger.info("Current bindings after registration: %s", meta._ConverterMeta._bindings) diff --git a/tests/test_durable_functions.py b/tests/test_durable_functions.py index 043b373d..1f319d61 100644 --- a/tests/test_durable_functions.py +++ b/tests/test_durable_functions.py @@ -5,10 +5,10 @@ import json from azure.functions.durable_functions import ( - OrchestrationTriggerConverter, - EnitityTriggerConverter, - ActivityTriggerConverter, - DurableClientConverter + LegacyOrchestrationTriggerConverter, + LegacyEnitityTriggerConverter, + LegacyActivityTriggerConverter, + LegacyDurableClientConverter ) from azure.functions._durable_functions import ( OrchestrationContext, @@ -17,7 +17,7 @@ from azure.functions.meta import Datum CONTEXT_CLASSES = [OrchestrationContext, EntityContext] -CONVERTERS = [OrchestrationTriggerConverter, EnitityTriggerConverter] +CONVERTERS = [LegacyOrchestrationTriggerConverter, LegacyEnitityTriggerConverter] class TestDurableFunctions(unittest.TestCase): @@ -127,7 +127,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 +160,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 +173,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 +217,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 +226,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 +235,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 +254,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 +265,82 @@ 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) From 37714d4117c9f2f6721df0223744ea89b6f595b4 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 22 Jul 2026 13:43:21 -0600 Subject: [PATCH 4/6] Address review comments: quiet logs, if/else selection, use get_durable_package, add v2 converter tests - Revert version bump 2.3.0b2 -> 2.3.0b1 (handled by release process) - Move non-essential logs to debug and strip all but module-selection-path logs - Replace try/except with if/else for v1/v2 detection logs - DurableClientConverter uses get_durable_package() instead of plain import - Reorder registration so v2 is the if and legacy is the else fallback - Add tests for the v2 converters and register_durable_converters/get_durable_package --- azure/functions/__init__.py | 2 +- .../functions/decorators/durable_functions.py | 16 +- azure/functions/durable_functions.py | 26 +- tests/test_durable_functions.py | 284 +++++++++++++++++- 4 files changed, 300 insertions(+), 28 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 9ead9aa7..fbf05057 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -120,4 +120,4 @@ 'mcp_content', ) -__version__ = '2.3.0b2' +__version__ = '2.3.0b1' diff --git a/azure/functions/decorators/durable_functions.py b/azure/functions/decorators/durable_functions.py index c2b00698..0274bb28 100644 --- a/azure/functions/decorators/durable_functions.py +++ b/azure/functions/decorators/durable_functions.py @@ -19,25 +19,21 @@ def get_durable_package(): If neither package is installed, we return None. """ - _logger.info("Attempting to import Durable Functions package.") global df if df: - _logger.info("Durable Functions package already loaded. DF: %s", df) return df try: import azure.durable_functions as durable_functions # noqa - _logger.info("`azure.durable_functions` package found.") except ImportError: - _logger.info("`azure.durable_functions` package not found.") + _logger.debug("`azure.durable_functions` package not found.") return None - try: - if hasattr(durable_functions, 'version') and durable_functions.version.startswith("2."): - _logger.info("Using `azure.durable_functions` v2.x package.") - except Exception: - _logger.info("Using `azure.durable_functions` v1.x package.") + 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 \ No newline at end of file + return df diff --git a/azure/functions/durable_functions.py b/azure/functions/durable_functions.py index b3a806c7..0d249c69 100644 --- a/azure/functions/durable_functions.py +++ b/azure/functions/durable_functions.py @@ -341,7 +341,7 @@ def has_trigger_support(cls) -> bool: @classmethod def check_input_type_annotation(cls, pytype: type) -> bool: - import azure.durable_functions as adf + adf = get_durable_package() return issubclass(pytype, (str, bytes, adf.DurableFunctionsClient)) @classmethod @@ -373,7 +373,7 @@ def encode(cls, obj: typing.Any, *, @classmethod def decode(cls, data: meta.Datum, *, trigger_metadata) -> typing.Any: - import azure.durable_functions as adf + adf = get_durable_package() return adf.DurableFunctionsClient(data.value) @@ -382,30 +382,24 @@ def register_durable_converters(): Registers the appropriate Durable Functions converters based on the installed Durable Functions package. """ - _logger.info("Registering Durable Functions converters based on ") pkg = get_durable_package() if pkg is None: return - _logger.info("Durable Functions package loaded: %s", pkg.__name__) - _logger.info("Current bindings before registration: %s", meta._ConverterMeta._bindings) 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 not hasattr(pkg, 'version'): - _logger.info("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 - elif hasattr(pkg, 'version') and pkg.version.startswith("2."): - _logger.info("Registering Durable Task Durable Functions converters.") + 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 - - _logger.info("Durable Functions converters registered.") - _logger.info("Current bindings after registration: %s", meta._ConverterMeta._bindings) + 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/tests/test_durable_functions.py b/tests/test_durable_functions.py index 1f319d61..bcf1df7a 100644 --- a/tests/test_durable_functions.py +++ b/tests/test_durable_functions.py @@ -1,19 +1,28 @@ # 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 + LegacyDurableClientConverter, + OrchestrationTriggerConverter, + EnitityTriggerConverter, + ActivityTriggerConverter, + 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] @@ -344,3 +353,276 @@ def test_durable_client_converter_decode(self): data = Datum(type="weird", value="???") with self.assertRaises(ValueError): 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) From 5ec891ec5c7d0b7563a7cc0259b25ed1cc00a376 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Wed, 22 Jul 2026 15:21:36 -0600 Subject: [PATCH 5/6] Address review: lazy converter registration + logging cleanup - Register durable converters lazily on first _ConverterMeta.get() lookup instead of at azure.functions import time, avoiding a re-entrant import of the durable SDK (which imports azure.functions) - Move the DurableFunctions logger into _get_durable_blueprint and use debug - Group 'import logging' with the other stdlib imports in durable_functions.py - Add deferred-registration tests in test_meta.py --- azure/functions/__init__.py | 6 +++- azure/functions/decorators/function_app.py | 5 ++-- azure/functions/durable_functions.py | 4 +-- azure/functions/meta.py | 25 +++++++++++++++- tests/test_meta.py | 34 ++++++++++++++++++++++ 5 files changed, 67 insertions(+), 7 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index fbf05057..133ac028 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -49,7 +49,11 @@ from . import connectors # NoQA -register_durable_converters() +# Register Durable Functions converters lazily on the first binding lookup. +# 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. +get_binding_registry().register_deferred(register_durable_converters) __all__ = ( diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 65011a5f..e1910925 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -63,8 +63,6 @@ from azure.functions.decorators.mysql import MySqlInput, MySqlOutput, \ MySqlTrigger -_logger = logging.getLogger('azure.functions.DurableFunctions') - class Function(object): """ @@ -355,7 +353,8 @@ def _get_durable_blueprint(self): """Attempt to import the Durable Functions SDK from which DF decorators are implemented. """ - _logger.info("Getting Durable Functions blueprint.") + logger = logging.getLogger('azure.functions.DurableFunctions') + logger.debug("Getting Durable Functions blueprint.") df = get_durable_package() if df: df_bp = df.Blueprint() diff --git a/azure/functions/durable_functions.py b/azure/functions/durable_functions.py index 0d249c69..70a6328b 100644 --- a/azure/functions/durable_functions.py +++ b/azure/functions/durable_functions.py @@ -1,14 +1,14 @@ # 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 -import logging _logger = logging.getLogger('azure.functions.DurableFunctions') diff --git a/azure/functions/meta.py b/azure/functions/meta.py index 09314f8d..8674073e 100644 --- a/azure/functions/meta.py +++ b/azure/functions/meta.py @@ -5,7 +5,7 @@ import collections.abc import datetime import re -from typing import Dict, Optional, Union, Tuple, Mapping, Any +from typing import Callable, Dict, List, Optional, Union, Tuple, Mapping, Any from ._jsonutils import json from ._thirdparty import typing_inspect @@ -84,6 +84,8 @@ def __repr__(self): class _ConverterMeta(abc.ABCMeta): _bindings: Dict[str, type] = {} + _deferred_registrations: List[Callable[[], None]] = [] + _deferred_registrations_done: bool = False def __new__(mcls, name, bases, dct, *, binding: Optional[str], @@ -105,8 +107,29 @@ def __new__(mcls, name, bases, dct, *, return cls + @classmethod + def register_deferred(cls, callback: Callable[[], None]) -> None: + """Register a callback that lazily registers converters on the first + 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. + """ + cls._deferred_registrations.append(callback) + + @classmethod + def _run_deferred_registrations(cls): + if cls._deferred_registrations_done: + return + cls._deferred_registrations_done = True + while cls._deferred_registrations: + cls._deferred_registrations.pop(0)() + @classmethod def get(cls, binding_name): + cls._run_deferred_registrations() return cls._bindings.get(binding_name) def has_trigger_support(cls) -> bool: diff --git a/tests/test_meta.py b/tests/test_meta.py index c13657a5..e52c6c2c 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -254,3 +254,37 @@ 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) + self._saved_done = registry._deferred_registrations_done + registry._deferred_registrations = [] + registry._deferred_registrations_done = False + + def tearDown(self): + registry = meta._ConverterMeta + registry._deferred_registrations = self._saved_registrations + registry._deferred_registrations_done = self._saved_done + + 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]) From 41a836b2a809127962349fc0a7d0d024402bb991 Mon Sep 17 00:00:00 2001 From: Andy Staples Date: Thu, 23 Jul 2026 16:05:39 -0600 Subject: [PATCH 6/6] Name-aware durable deferred registration --- azure/functions/__init__.py | 20 ++++++++++++----- azure/functions/meta.py | 45 ++++++++++++++++++++++++++----------- tests/test_meta.py | 24 +++++++++++++++++--- 3 files changed, 68 insertions(+), 21 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 133ac028..c8ba9635 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -49,11 +49,21 @@ from . import connectors # NoQA -# Register Durable Functions converters lazily on the first binding lookup. -# 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. -get_binding_registry().register_deferred(register_durable_converters) +# 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__ = ( diff --git a/azure/functions/meta.py b/azure/functions/meta.py index 8674073e..8afdf070 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 Callable, Dict, List, 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,8 +85,8 @@ def __repr__(self): class _ConverterMeta(abc.ABCMeta): _bindings: Dict[str, type] = {} - _deferred_registrations: List[Callable[[], None]] = [] - _deferred_registrations_done: bool = False + _deferred_registrations: List[ + Tuple[Callable[[], None], Optional[frozenset]]] = [] def __new__(mcls, name, bases, dct, *, binding: Optional[str], @@ -108,28 +109,46 @@ def __new__(mcls, name, bases, dct, *, return cls @classmethod - def register_deferred(cls, callback: Callable[[], None]) -> None: - """Register a callback that lazily registers converters on the first - binding lookup. + 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. """ - cls._deferred_registrations.append(callback) + names = frozenset(binding_names) if binding_names is not None else None + cls._deferred_registrations.append((callback, names)) @classmethod - def _run_deferred_registrations(cls): - if cls._deferred_registrations_done: + def _run_deferred_registrations(cls, binding_name): + if not cls._deferred_registrations: return - cls._deferred_registrations_done = True - while cls._deferred_registrations: - cls._deferred_registrations.pop(0)() + 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() + cls._run_deferred_registrations(binding_name) return cls._bindings.get(binding_name) def has_trigger_support(cls) -> bool: diff --git a/tests/test_meta.py b/tests/test_meta.py index e52c6c2c..dff9f6b7 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -262,14 +262,11 @@ class TestDeferredRegistration(unittest.TestCase): def setUp(self): registry = meta._ConverterMeta self._saved_registrations = list(registry._deferred_registrations) - self._saved_done = registry._deferred_registrations_done registry._deferred_registrations = [] - registry._deferred_registrations_done = False def tearDown(self): registry = meta._ConverterMeta registry._deferred_registrations = self._saved_registrations - registry._deferred_registrations_done = self._saved_done def test_deferred_callback_runs_on_first_get(self): calls = [] @@ -288,3 +285,24 @@ def test_deferred_callback_runs_only_once(self): 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])