Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions azure/functions/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -43,6 +44,7 @@
from . import sql # NoQA
from . import warmup # NoQA
from . import mysql # NoQA
from . import connector # NoQA


__all__ = (
Expand Down Expand Up @@ -76,6 +78,7 @@
'WarmUpContext',
'MySqlRow',
'MySqlRowList',
'ConnectorContext',

# Middlewares
'WsgiMiddleware',
Expand Down
60 changes: 60 additions & 0 deletions azure/functions/connector.py
Original file line number Diff line number Diff line change
@@ -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))
1 change: 1 addition & 0 deletions azure/functions/decorators/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@
MCP_TOOL_TRIGGER = "mcpToolTrigger"
MCP_RESOURCE_TRIGGER = "mcpResourceTrigger"
MCP_PROMPT_TRIGGER = "mcpPromptTrigger"
CONNECTOR_TRIGGER = "connectorTrigger"
45 changes: 44 additions & 1 deletion azure/functions/decorators/function_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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, \
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions azure/functions/decorators/generic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
49 changes: 49 additions & 0 deletions tests/decorators/test_connector.py
Original file line number Diff line number Diff line change
@@ -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)
168 changes: 168 additions & 0 deletions tests/test_connector.py
Original file line number Diff line number Diff line change
@@ -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)
Loading