diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index eacda29d..ac3f0fc7 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -43,6 +43,7 @@ from . import sql # NoQA from . import warmup # NoQA from . import mysql # NoQA +from . import connectors # NoQA __all__ = ( diff --git a/azure/functions/connectors.py b/azure/functions/connectors.py new file mode 100644 index 00000000..c72d9b7f --- /dev/null +++ b/azure/functions/connectors.py @@ -0,0 +1,55 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import typing + +from . import meta + + +class ConnectorTriggerConverter(meta.InConverter, binding='connectorTrigger', + trigger=True): + + @classmethod + def check_input_type_annotation(cls, pytype: type) -> bool: + return issubclass(pytype, (str, dict, bytes)) + + @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/connectors.py b/azure/functions/decorators/connectors.py new file mode 100644 index 00000000..995e6d23 --- /dev/null +++ b/azure/functions/decorators/connectors.py @@ -0,0 +1,21 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Optional + +from azure.functions.decorators.core import Trigger, \ + DataType + + +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/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 629e7279..78c3a390 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -43,6 +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 .connectors import ConnectorTrigger from .generic import GenericInputBinding, GenericTrigger, GenericOutputBinding from .openai import _AssistantSkillTrigger, OpenAIModels, _TextCompletionInput, \ _AssistantCreateOutput, \ @@ -1557,6 +1558,46 @@ def decorator(): return wrap + def connector_trigger(self, + arg_name: str, + data_type: Optional[Union[DataType, str]] = None, + **kwargs) -> Callable[..., Any]: + """ + The `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. + + :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/tests/decorators/test_connector.py b/tests/decorators/test_connector.py new file mode 100644 index 00000000..239e4861 --- /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.connectors 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..6260b2b5 --- /dev/null +++ b/tests/test_connector.py @@ -0,0 +1,165 @@ +# 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.connectors import ConnectorTriggerConverter + + +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)) + + 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') + + +class TestConnectorDecoratorIntegration(unittest.TestCase): + """Integration tests for the connector trigger decorator""" + + def test_decorator_creates_function_with_trigger(self): + app = func.FunctionApp() + + @app.connector_trigger(arg_name="payload") + def connector_function(payload): + return f"Received: {payload}" + + # Get the built function + funcs = app.get_functions() + self.assertEqual(len(funcs), 1) + + built_func = funcs[0] + self.assertIsNotNone(built_func.get_trigger()) + self.assertEqual(built_func.get_trigger().type, 'connectorTrigger') + + def test_decorator_with_data_type(self): + app = func.FunctionApp() + + @app.connector_trigger( + arg_name="context", + data_type=func.DataType.STRING + ) + def connector_with_datatype(context): + return context + + funcs = app.get_functions() + self.assertEqual(len(funcs), 1) + + built_func = funcs[0] + trigger = built_func.get_trigger() + self.assertIsNotNone(trigger) + self.assertEqual(trigger.get_dict_repr()['dataType'], func.DataType.STRING) + + def test_decorator_with_kwargs(self): + app = func.FunctionApp() + + @app.connector_trigger( + arg_name="data", + custom_field="custom_value", + another_property=123 + ) + def connector_with_kwargs(data): + return data + + funcs = app.get_functions() + self.assertEqual(len(funcs), 1) + + built_func = funcs[0] + bindings = built_func.get_bindings() + self.assertEqual(len(bindings), 1) + + trigger_dict = bindings[0].get_dict_repr() + self.assertEqual(trigger_dict['type'], 'connectorTrigger') + self.assertEqual(trigger_dict['customField'], 'custom_value') + self.assertEqual(trigger_dict['anotherProperty'], 123)