From 7f4bfdf9e6242de6bb4ac4f89edbedb1c194f9ce Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 24 Jul 2026 11:10:11 -0500 Subject: [PATCH 1/4] feat: add register_converter API --- azure/functions/__init__.py | 3 ++- azure/functions/meta.py | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index f9f6df4f..6aef67cd 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -23,7 +23,7 @@ from ._http_asgi import AsgiMiddleware from .kafka import KafkaEvent, KafkaConverter, KafkaTriggerConverter from .mcp import MCPToolContext, PromptInvocationContext -from .meta import get_binding_registry +from .meta import get_binding_registry, register_converter from ._queue import QueueMessage from ._servicebus import ServiceBusMessage from ._sql import SqlRow, SqlRowList @@ -51,6 +51,7 @@ __all__ = ( # Functions 'get_binding_registry', + 'register_converter', # Generics. 'Context', diff --git a/azure/functions/meta.py b/azure/functions/meta.py index 09314f8d..64454d54 100644 --- a/azure/functions/meta.py +++ b/azure/functions/meta.py @@ -407,3 +407,36 @@ def encode(cls, obj: Any, *, def get_binding_registry(): return _ConverterMeta + + +def register_converter( + binding_name: str, + converter_cls: type, + *, + overwrite: bool = False) -> None: + """Register or replace a converter for a binding name. + + By default raises RuntimeError if the binding is already registered, + requiring callers to explicitly pass overwrite=True to replace an + existing entry. This allows external packages (e.g. + azure-functions-durable) to override built-in converters without + accessing private internals. + + Parameters + ---------- + binding_name: + The binding type string as it appears in function.json, e.g. + ``'orchestrationTrigger'``. + converter_cls: + A class that is a subclass of InConverter and/or OutConverter. + overwrite: + If True, silently replace any existing registration for + ``binding_name``. If False (default), raise RuntimeError when the + binding is already registered. + """ + if not overwrite and binding_name in _ConverterMeta._bindings: + raise RuntimeError( + f'cannot register a converter for {binding_name!r} binding: ' + f'another converter for this binding has already been ' + f'registered. Pass overwrite=True to replace it.') + _ConverterMeta._bindings[binding_name] = converter_cls From da576114725a0d52aa126dabcc3ef7a8ab26a406 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 24 Jul 2026 12:07:21 -0500 Subject: [PATCH 2/4] allowlist + tests --- azure/functions/meta.py | 16 ++++++++ tests/test_meta.py | 86 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/azure/functions/meta.py b/azure/functions/meta.py index 64454d54..98fd95da 100644 --- a/azure/functions/meta.py +++ b/azure/functions/meta.py @@ -15,6 +15,16 @@ ) +# Binding names whose converters are permitted to be overridden via +# register_converter(). Only durable-related bindings are overridable. +_OVERRIDABLE_BINDINGS: frozenset = frozenset({ + 'orchestrationTrigger', + 'entityTrigger', + 'activityTrigger', + 'durableClient', +}) + + def is_iterable_type_annotation(annotation: object, pytype: object) -> bool: is_iterable_anno = ( typing_inspect.is_generic_type(annotation) @@ -434,6 +444,12 @@ def register_converter( ``binding_name``. If False (default), raise RuntimeError when the binding is already registered. """ + if binding_name not in _OVERRIDABLE_BINDINGS: + raise ValueError( + f'cannot override converter for {binding_name!r}: ' + f'only durable-related bindings may be overridden via ' + f'register_converter(). Overridable bindings: ' + f'{sorted(_OVERRIDABLE_BINDINGS)}') if not overwrite and binding_name in _ConverterMeta._bindings: raise RuntimeError( f'cannot register a converter for {binding_name!r} binding: ' diff --git a/tests/test_meta.py b/tests/test_meta.py index c13657a5..3e05013c 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -254,3 +254,89 @@ def _parse_datetime(self, datetime_str): def _parse_timedelta(self, timedelta_str): return meta._BaseConverter._parse_timedelta(timedelta_str) + + +class TestRegisterConverter(unittest.TestCase): + + def setUp(self): + # Snapshot the live bindings registry so each test is isolated. + self._saved_bindings = dict(meta._ConverterMeta._bindings) + + def tearDown(self): + # Restore the registry exactly as it was before the test ran. + meta._ConverterMeta._bindings.clear() + meta._ConverterMeta._bindings.update(self._saved_bindings) + + @staticmethod + def _make_dummy_converter(): + """Return a fresh class usable as a placeholder converter.""" + class _Dummy: + pass + return _Dummy + + # ------------------------------------------------------------------ # + # Allow-list enforcement # + # ------------------------------------------------------------------ # + + def test_non_durable_binding_raises_value_error(self): + with self.assertRaises(ValueError) as ctx: + meta.register_converter('httpTrigger', + self._make_dummy_converter()) + self.assertIn('httpTrigger', str(ctx.exception)) + + def test_non_durable_binding_with_overwrite_still_raises_value_error(self): + # overwrite=True must not bypass the allow-list. + with self.assertRaises(ValueError): + meta.register_converter('queueTrigger', + self._make_dummy_converter(), + overwrite=True) + + # ------------------------------------------------------------------ # + # Successful registration # + # ------------------------------------------------------------------ # + + def test_all_durable_bindings_are_overridable(self): + for name in ('orchestrationTrigger', 'entityTrigger', + 'activityTrigger', 'durableClient'): + dummy = self._make_dummy_converter() + meta.register_converter(name, dummy, overwrite=True) + self.assertIs( + meta._ConverterMeta._bindings[name], dummy, + msg=f'{name!r} was not registered correctly') + + def test_register_new_durable_binding_not_previously_in_registry(self): + meta._ConverterMeta._bindings.pop('orchestrationTrigger', None) + dummy = self._make_dummy_converter() + meta.register_converter('orchestrationTrigger', dummy) + self.assertIs(meta._ConverterMeta._bindings['orchestrationTrigger'], + dummy) + + # ------------------------------------------------------------------ # + # overwrite=False guard # + # ------------------------------------------------------------------ # + + def test_already_registered_without_overwrite_raises_runtime_error(self): + dummy = self._make_dummy_converter() + meta._ConverterMeta._bindings['orchestrationTrigger'] = dummy + with self.assertRaises(RuntimeError) as ctx: + meta.register_converter('orchestrationTrigger', + self._make_dummy_converter()) + self.assertIn('orchestrationTrigger', str(ctx.exception)) + + def test_already_registered_with_overwrite_true_replaces_entry(self): + original = self._make_dummy_converter() + replacement = self._make_dummy_converter() + meta._ConverterMeta._bindings['orchestrationTrigger'] = original + meta.register_converter('orchestrationTrigger', replacement, + overwrite=True) + self.assertIs(meta._ConverterMeta._bindings['orchestrationTrigger'], + replacement) + + # ------------------------------------------------------------------ # + # Public API surface # + # ------------------------------------------------------------------ # + + def test_register_converter_is_exported_from_azure_functions(self): + import azure.functions as af + self.assertTrue(hasattr(af, 'register_converter')) + self.assertIn('register_converter', af.__all__) From 5a7a92de2de2d8649fa318f10a4892d7877f92a3 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Fri, 24 Jul 2026 12:27:18 -0500 Subject: [PATCH 3/4] lint --- tests/test_meta.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_meta.py b/tests/test_meta.py index 3e05013c..666f7631 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -328,7 +328,7 @@ def test_already_registered_with_overwrite_true_replaces_entry(self): replacement = self._make_dummy_converter() meta._ConverterMeta._bindings['orchestrationTrigger'] = original meta.register_converter('orchestrationTrigger', replacement, - overwrite=True) + overwrite=True) self.assertIs(meta._ConverterMeta._bindings['orchestrationTrigger'], replacement) From 0e25f9aa4159d9864b4f774ed06f1c2d57a1f291 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:58:19 -0500 Subject: [PATCH 4/4] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- azure/functions/meta.py | 19 ++++++++++++------- tests/test_meta.py | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/azure/functions/meta.py b/azure/functions/meta.py index 98fd95da..9c63a709 100644 --- a/azure/functions/meta.py +++ b/azure/functions/meta.py @@ -428,9 +428,9 @@ def register_converter( By default raises RuntimeError if the binding is already registered, requiring callers to explicitly pass overwrite=True to replace an - existing entry. This allows external packages (e.g. - azure-functions-durable) to override built-in converters without - accessing private internals. + existing entry. This API is intended for integration packages (e.g. + azure-functions-durable) that need to override built-in durable + converters without accessing private internals. Parameters ---------- @@ -444,12 +444,17 @@ def register_converter( ``binding_name``. If False (default), raise RuntimeError when the binding is already registered. """ + if not isinstance(converter_cls, type): + raise TypeError('converter_cls must be a class') + if not issubclass(converter_cls, (InConverter, OutConverter)): + raise TypeError( + 'converter_cls must be a subclass of InConverter and/or ' + 'OutConverter') if binding_name not in _OVERRIDABLE_BINDINGS: raise ValueError( - f'cannot override converter for {binding_name!r}: ' - f'only durable-related bindings may be overridden via ' - f'register_converter(). Overridable bindings: ' - f'{sorted(_OVERRIDABLE_BINDINGS)}') + f'cannot register converter for {binding_name!r}: ' + f'register_converter() only supports durable-related bindings. ' + f'Overridable bindings: {sorted(_OVERRIDABLE_BINDINGS)}') if not overwrite and binding_name in _ConverterMeta._bindings: raise RuntimeError( f'cannot register a converter for {binding_name!r} binding: ' diff --git a/tests/test_meta.py b/tests/test_meta.py index 666f7631..18522c29 100644 --- a/tests/test_meta.py +++ b/tests/test_meta.py @@ -269,9 +269,20 @@ def tearDown(self): @staticmethod def _make_dummy_converter(): - """Return a fresh class usable as a placeholder converter.""" - class _Dummy: - pass + """Return a fresh converter class usable as a placeholder.""" + class _Dummy(meta.InConverter, binding=None): + @classmethod + def check_input_type_annotation(cls, pytype: type) -> bool: + return True + + @classmethod + def decode(cls, data: meta.Datum, *, trigger_metadata): + return None + + @classmethod + def has_implicit_output(cls) -> bool: + return False + return _Dummy # ------------------------------------------------------------------ #