diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index eacda29d..c4ae1ffb 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -22,6 +22,7 @@ from ._http_asgi import AsgiMiddleware from .kafka import KafkaEvent, KafkaConverter, KafkaTriggerConverter from .mcp import MCPToolContext, PromptInvocationContext +from .connector import ConnectorContext from .meta import get_binding_registry from ._queue import QueueMessage from ._servicebus import ServiceBusMessage @@ -43,6 +44,7 @@ from . import sql # NoQA from . import warmup # NoQA from . import mysql # NoQA +from . import connector # NoQA __all__ = ( @@ -76,6 +78,7 @@ 'WarmUpContext', 'MySqlRow', 'MySqlRowList', + 'ConnectorContext', # Middlewares 'WsgiMiddleware', diff --git a/azure/functions/connector.py b/azure/functions/connector.py new file mode 100644 index 00000000..4b6d73b0 --- /dev/null +++ b/azure/functions/connector.py @@ -0,0 +1,60 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import typing + +from . import meta + + +class ConnectorContext(typing.Dict[str, typing.Any]): + """Context object for connector triggers.""" + pass + + +class ConnectorTriggerConverter(meta.InConverter, binding='connectorTrigger', + trigger=True): + + @classmethod + def check_input_type_annotation(cls, pytype: type) -> bool: + return issubclass(pytype, (str, dict, bytes, ConnectorContext)) + + @classmethod + def has_implicit_output(cls) -> bool: + return True + + @classmethod + def decode(cls, data: meta.Datum, *, trigger_metadata): + """ + Decode incoming connector trigger request data. + Returns the raw data in its native format (string, dict, bytes). + """ + # Handle different data types appropriately + if data.type == 'json': + # If it's already parsed JSON, use the value directly + return data.value + elif data.type == 'string': + # If it's a string, use it as-is + return data.value + elif data.type == 'bytes': + return data.value + else: + # Fallback to python_value for other types + return data.python_value if hasattr(data, 'python_value') else data.value + + @classmethod + def encode(cls, obj: typing.Any, *, expected_type: typing.Optional[type] = None): + """ + Encode the return value from connector trigger functions. + """ + if obj is None: + return meta.Datum(type='string', value='') + elif isinstance(obj, str): + return meta.Datum(type='string', value=obj) + elif isinstance(obj, (bytes, bytearray)): + return meta.Datum(type='bytes', value=bytes(obj)) + elif isinstance(obj, dict): + import json + return meta.Datum(type='string', value=json.dumps(obj)) + else: + # Convert other types to string + return meta.Datum(type='string', value=str(obj)) diff --git a/azure/functions/decorators/constants.py b/azure/functions/decorators/constants.py index b7e38587..dfdbebe7 100644 --- a/azure/functions/decorators/constants.py +++ b/azure/functions/decorators/constants.py @@ -48,3 +48,4 @@ MCP_TOOL_TRIGGER = "mcpToolTrigger" MCP_RESOURCE_TRIGGER = "mcpResourceTrigger" MCP_PROMPT_TRIGGER = "mcpPromptTrigger" +CONNECTOR_TRIGGER = "connectorTrigger" diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index a1a91e24..aea83d90 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -43,7 +43,7 @@ from azure.functions.decorators.utils import parse_singular_param_to_enum, \ parse_iterable_param_to_enums, StringifyEnumJsonEncoder from azure.functions.http import HttpRequest -from .generic import GenericInputBinding, GenericTrigger, GenericOutputBinding +from .generic import GenericInputBinding, GenericTrigger, GenericOutputBinding, ConnectorTrigger from .openai import _AssistantSkillTrigger, OpenAIModels, _TextCompletionInput, \ _AssistantCreateOutput, \ _AssistantQueryInput, _AssistantPostInput, InputType, _EmbeddingsInput, \ @@ -1557,6 +1557,49 @@ def decorator(): return wrap + def generic_connector_trigger(self, + arg_name: str, + data_type: Optional[Union[DataType, str]] = None, + **kwargs + ) -> Callable[..., Any]: + """ + The `generic_connector_trigger` decorator adds :class:`ConnectorTrigger` to the + :class:`FunctionBuilder` object for building a :class:`Function` used in the + worker function indexing model. + + This is equivalent to defining a connector trigger in the `function.json`, which + triggers the function to execute when connector trigger events are received by + the host. + + All optional fields will be given default values by the function host when + they are parsed. + + Ref: https://aka.ms/azure-function-binding-custom + + :param arg_name: The name of the trigger parameter in the function code. + :param data_type: Defines how the Functions runtime should treat the + parameter value. + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding JSON. + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_trigger( + trigger=ConnectorTrigger( + name=arg_name, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + def mcp_tool_trigger(self, arg_name: str, tool_name: str, diff --git a/azure/functions/decorators/generic.py b/azure/functions/decorators/generic.py index 15a2acc1..e9cad1e7 100644 --- a/azure/functions/decorators/generic.py +++ b/azure/functions/decorators/generic.py @@ -46,3 +46,18 @@ def __init__(self, data_type: Optional[DataType] = None, **kwargs): super().__init__(name=name, data_type=data_type, type=type) + + +class ConnectorTrigger(Trigger): + + @staticmethod + def get_binding_name(): + from azure.functions.decorators.constants import CONNECTOR_TRIGGER + return CONNECTOR_TRIGGER + + def __init__(self, + name: str, + data_type: Optional[DataType] = None, + **kwargs): + from azure.functions.decorators.constants import CONNECTOR_TRIGGER + super().__init__(name=name, data_type=data_type, type=CONNECTOR_TRIGGER) diff --git a/tests/decorators/test_connector.py b/tests/decorators/test_connector.py new file mode 100644 index 00000000..6bbf947c --- /dev/null +++ b/tests/decorators/test_connector.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + +from azure.functions.decorators.constants import CONNECTOR_TRIGGER +from azure.functions.decorators.core import BindingDirection, DataType +from azure.functions.decorators.generic import ConnectorTrigger + + +class TestConnectorTrigger(unittest.TestCase): + def test_connector_trigger_valid_creation(self): + trigger = ConnectorTrigger(name="payload", + data_type=DataType.UNDEFINED, + dummy_field="dummy") + + self.assertEqual(trigger.get_binding_name(), CONNECTOR_TRIGGER) + self.assertEqual(trigger.get_dict_repr(), { + "type": CONNECTOR_TRIGGER, + "direction": BindingDirection.IN, + 'dummyField': 'dummy', + "name": "payload", + "dataType": DataType.UNDEFINED + }) + + def test_connector_trigger_minimal_creation(self): + trigger = ConnectorTrigger(name="req") + + self.assertEqual(trigger.get_binding_name(), "connectorTrigger") + self.assertEqual(trigger.get_dict_repr(), { + "type": "connectorTrigger", + "direction": BindingDirection.IN, + "name": "req" + }) + + def test_connector_trigger_with_kwargs(self): + trigger = ConnectorTrigger( + name="context", + data_type=DataType.STRING, + custom_property="custom_value", + another_field=123 + ) + + self.assertEqual(trigger.get_binding_name(), "connectorTrigger") + dict_repr = trigger.get_dict_repr() + self.assertEqual(dict_repr["type"], "connectorTrigger") + self.assertEqual(dict_repr["name"], "context") + self.assertEqual(dict_repr["dataType"], DataType.STRING) + self.assertEqual(dict_repr["customProperty"], "custom_value") + self.assertEqual(dict_repr["anotherField"], 123) diff --git a/tests/test_connector.py b/tests/test_connector.py new file mode 100644 index 00000000..67204cb9 --- /dev/null +++ b/tests/test_connector.py @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest +import json +import azure.functions as func +from azure.functions.meta import Datum +from azure.functions.connector import (ConnectorTriggerConverter, + ConnectorContext) + + +class TestConnectorTriggerConverter(unittest.TestCase): + """Unit tests for ConnectorTriggerConverter""" + + def test_check_input_type_annotation_valid_types(self): + self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(str)) + self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(dict)) + self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(bytes)) + self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(ConnectorContext)) + + def test_check_input_type_annotation_invalid_type(self): + with self.assertRaises(TypeError): + ConnectorTriggerConverter.check_input_type_annotation(123) # not a type + + class Dummy: + pass + self.assertFalse(ConnectorTriggerConverter.check_input_type_annotation(Dummy)) + + def test_has_implicit_output(self): + self.assertTrue(ConnectorTriggerConverter.has_implicit_output()) + + def test_decode_json(self): + data = Datum(type='json', value={'foo': 'bar', 'count': 42}) + result = ConnectorTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, {'foo': 'bar', 'count': 42}) + + def test_decode_string(self): + data = Datum(type='string', value='hello connector') + result = ConnectorTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, 'hello connector') + + def test_decode_bytes(self): + data = Datum(type='bytes', value=b'binary data') + result = ConnectorTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, b'binary data') + + def test_decode_other_without_python_value(self): + data = Datum(type='other', value='fallback value') + result = ConnectorTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, 'fallback value') + + def test_decode_other_with_python_value(self): + class MockDatum: + type = 'custom' + value = 'original' + python_value = 'python version' + + data = MockDatum() + result = ConnectorTriggerConverter.decode(data, trigger_metadata={}) + self.assertEqual(result, 'python version') + + def test_encode_none(self): + result = ConnectorTriggerConverter.encode(None) + self.assertEqual(result.type, 'string') + self.assertEqual(result.value, '') + + def test_encode_string(self): + result = ConnectorTriggerConverter.encode('hello connector') + self.assertEqual(result.type, 'string') + self.assertEqual(result.value, 'hello connector') + + def test_encode_bytes(self): + result = ConnectorTriggerConverter.encode(b'\x00\x01\x02') + self.assertEqual(result.type, 'bytes') + self.assertEqual(result.value, b'\x00\x01\x02') + + def test_encode_bytearray(self): + result = ConnectorTriggerConverter.encode(bytearray(b'\x01\x02\x03')) + self.assertEqual(result.type, 'bytes') + self.assertEqual(result.value, b'\x01\x02\x03') + + def test_encode_dict(self): + input_dict = {'status': 'success', 'data': [1, 2, 3]} + result = ConnectorTriggerConverter.encode(input_dict) + self.assertEqual(result.type, 'string') + # Parse the JSON to verify it's correct + parsed = json.loads(result.value) + self.assertEqual(parsed, input_dict) + + def test_encode_dict_with_nested_data(self): + input_dict = { + 'name': 'test', + 'nested': {'key': 'value'}, + 'list': [1, 2, 3] + } + result = ConnectorTriggerConverter.encode(input_dict) + self.assertEqual(result.type, 'string') + parsed = json.loads(result.value) + self.assertEqual(parsed, input_dict) + + def test_encode_other_type(self): + result = ConnectorTriggerConverter.encode(42) + self.assertEqual(result.type, 'string') + self.assertEqual(result.value, '42') + + result = ConnectorTriggerConverter.encode(True) + self.assertEqual(result.type, 'string') + self.assertEqual(result.value, 'True') + + def test_connector_context_is_dict_subclass(self): + # Verify ConnectorContext is a dict subclass + self.assertTrue(issubclass(ConnectorContext, dict)) + + # Test that it can be instantiated and used as a dict + ctx = ConnectorContext({'key': 'value'}) + self.assertEqual(ctx['key'], 'value') + + +class TestConnectorDecoratorIntegration(unittest.TestCase): + """Integration tests for the connector trigger decorator""" + + def test_decorator_creates_function_with_trigger(self): + app = func.FunctionApp() + + @app.generic_connector_trigger(arg_name="payload") + def connector_function(payload): + return f"Received: {payload}" + + # Verify the function was decorated + self.assertIsNotNone(connector_function) + + # Check that it has the expected structure + # The decorator should return a Function object + self.assertTrue(hasattr(connector_function, 'get_triggers')) + + def test_decorator_with_data_type(self): + app = func.FunctionApp() + + @app.generic_connector_trigger( + arg_name="context", + data_type=func.DataType.STRING + ) + def connector_with_datatype(context): + return context + + self.assertIsNotNone(connector_with_datatype) + self.assertTrue(hasattr(connector_with_datatype, 'get_triggers')) + + def test_decorator_with_kwargs(self): + app = func.FunctionApp() + + @app.generic_connector_trigger( + arg_name="data", + custom_field="custom_value", + another_property=123 + ) + def connector_with_kwargs(data): + return data + + self.assertIsNotNone(connector_with_kwargs) + + # Verify trigger was added with kwargs + triggers = connector_with_kwargs.get_triggers() + self.assertEqual(len(triggers), 1) + + trigger_dict = triggers[0].get_dict_repr() + self.assertEqual(trigger_dict['type'], 'connectorTrigger') + self.assertEqual(trigger_dict['customField'], 'custom_value') + self.assertEqual(trigger_dict['anotherProperty'], 123)