From be48b079efd399b632ca5884b0e37170cc24f818 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 12 Oct 2026 14:27:41 -0500 Subject: [PATCH 1/5] feat: extend connector_decorator --- .../skills/add-function-decorator/SKILL.md | 205 ++++++++++++++++++ azure/functions/__init__.py | 3 +- azure/functions/decorators/__init__.py | 1 + azure/functions/decorators/connectors.py | 36 ++- azure/functions/decorators/core.py | 12 + azure/functions/decorators/function_app.py | 32 ++- tests/decorators/test_connector.py | 128 ++++++++++- tests/test_connector.py | 114 ++++++++++ 8 files changed, 526 insertions(+), 5 deletions(-) create mode 100644 .github/skills/add-function-decorator/SKILL.md diff --git a/.github/skills/add-function-decorator/SKILL.md b/.github/skills/add-function-decorator/SKILL.md new file mode 100644 index 00000000..00766bf1 --- /dev/null +++ b/.github/skills/add-function-decorator/SKILL.md @@ -0,0 +1,205 @@ +--- +name: add-function-decorator +description: 'Add or extend an Azure Functions Python SDK decorator or binding. Use when implementing decorator arguments, snake_case APIs, enums and string parsing, public exports, pydocs, binding metadata serialization, converters, or decorator regression tests in this repository.' +--- + +# Add or extend a function decorator + +Implement decorators using this SDK's existing binding, function-builder, and +serialization conventions. This workflow also applies to adding arguments to an +existing decorator without breaking existing users. + +## 1. Review the reference PRs and current implementation + +Review the **Files changed** tabs, not only the PR descriptions: + +- [PR #339: add connector decorator](https://github.com/Azure/azure-functions-python-library/pull/339/files) + demonstrates the complete path from trigger binding to decorator, converter, + registration, and tests. It touched seven files: + [public exports and registration](../../../azure/functions/__init__.py), + [connector converter](../../../azure/functions/connectors.py), + [connector binding](../../../azure/functions/decorators/connectors.py), + [binding constants](../../../azure/functions/decorators/constants.py), + [decorator API](../../../azure/functions/decorators/function_app.py), + [binding tests](../../../tests/decorators/test_connector.py), and + [converter and integration tests](../../../tests/test_connector.py). +- [PR #330: MCP prompt trigger](https://github.com/Azure/azure-functions-python-library/pull/330/files) + demonstrates typed snake_case arguments, pydocs, forwarding to a binding, + public helper exports, and structured argument serialization. It touched + eight files: both package initializers, binding constants, the decorator API, + both MCP modules, and both MCP test modules. + +Use these PRs as precedent, not immutable templates. Inspect the current checkout +because implementations and requirements may have changed since those PRs. +If the PR diff cannot be retrieved, state that limitation and use local evidence; +do not claim to have reviewed unavailable content. + +Read the relevant binding module, its decorator method, tests, and the shared +[binding infrastructure](../../../azure/functions/decorators/core.py) and +[serialization utilities](../../../azure/functions/decorators/utils.py). +Use a comparable trigger/input/output binding as a second reference. + +Before editing: + +- Inspect the working tree and preserve unrelated user changes. +- Confirm extension-side binding type, JSON property names, types, required + fields, conditional requirements, defaults, and accepted enum values. +- Distinguish host defaults from SDK defaults. Do not guess extension behavior. +- Record a short plan and establish a focused test baseline. +- When available, use Pylance references or signature compatibility checks to + identify existing callers before changing a Python API. +- For Azure work, consult applicable Azure best practices when available. + +## 2. Define the public Python API + +- Use **snake_case** for decorator and binding constructor parameters: + `delivery_mode`, `trigger_config_name`, `max_batch_size`, and + `target_pending_event_threshold`, not their camelCase JSON counterparts. +- The decorator uses `arg_name`; pass it to the binding constructor as `name`. +- Give known extension fields explicit, accurately typed parameters instead of + hiding them in `**kwargs`. Retain `**kwargs` for additional binding fields. +- Use `Optional[T] = None` for fields that may be omitted. Prefer omission when + the host owns the default; do not eagerly insert a default into JSON. +- Preserve existing positional parameter order. In particular, extending + `connector_trigger` must not displace its second argument, `data_type`. +- Follow the surrounding `typing` style and supported Python versions rather + than introducing unrelated annotation modernization. +- Conditional requirements need optional signature parameters: Poll-only + settings must not become mandatory for Webhook callers. + +## 3. Add enums and support strings + +- Reuse existing enums, such as `Cardinality` and `DataType`, where appropriate. +- Add shared public binding enums to + [core.py](../../../azure/functions/decorators/core.py); keep binding-specific + types near the binding when that matches neighboring implementations. +- Use `StringifyEnum` when participating in the SDK's JSON serialization. + Name members in uppercase and document the enum and each member. +- Public enum parameters should accept `Optional[Union[EnumType, str]]` when + string input is supported. Normalize with `parse_singular_param_to_enum`; + do not implement a separate string parser. +- The helper looks up `param.upper()` by **member name**, preserves enum objects, + returns `None` for omission, and raises `KeyError` for invalid strings. Values + with different spelling from member names require deliberate mapping and tests. +- Normalize before enum comparisons and conditional validation. If direct binding + construction also supports strings, normalize in that constructor as well; + otherwise a string such as `"Poll"` can bypass a `DeliveryMode.POLL` check. +- Verify the **actual serialized string**, not only `.value`. The shared + `StringifyEnum.__str__` returns the member name, and + `StringifyEnumJsonEncoder` serializes `str(enum)`. A mixed-case wire value may + therefore require a narrowly scoped `__str__` override, as with `DeliveryMode` + emitting `"Poll"` and `"Webhook"`. Do not change shared enum serialization to + fix a single binding. +- Preserve existing `Cardinality` serialization; do not replace it with an + `is_batched` field merely because the extension exposes an equivalent option. +- Export public enums/helpers through + [decorators/__init__.py](../../../azure/functions/decorators/__init__.py) and + [functions/__init__.py](../../../azure/functions/__init__.py), including their + `__all__` entries. Test access through `azure.functions`. + +## 4. Implement the binding and decorator + +### Binding class + +- Add a binding type constant to + [constants.py](../../../azure/functions/decorators/constants.py) only when the + binding type is new. Implement `get_binding_name()` with the exact host name. +- Inherit from `Trigger`, `InputBinding`, or `OutputBinding` as appropriate. +- Assign each explicit field to its same-named snake_case instance attribute, + then call the base constructor with `name`, `data_type`, and other applicable + common fields. +- `Binding.get_dict_repr()` inspects constructor parameters and converts their + names to camelCase. `BuildDictMeta` tracks parameters, retains additional + kwargs, and removes `None` recursively. Prefer these mechanisms over custom + JSON assembly. Keep `**kwargs` in the constructor signature. +- Preserve valid falsy metadata (`0`, `False`, empty collections) unless the + extension contract explicitly rejects it. Check `is None` for omission rather + than treating every falsy value as absent. +- Put conditional required-field checks in the binding so direct construction + and decorator usage have consistent behavior. Raise an actionable `ValueError` + listing missing snake_case parameters. Validate only the applicable mode. +- Do not invent numeric ranges or restrictions. Confirm extension requirements + before enforcing positive values, nonempty strings, or mutually exclusive fields. + +### Decorator method + +- Add or extend the appropriate API method in + [function_app.py](../../../azure/functions/decorators/function_app.py). +- Follow the existing `@self._configure_function_builder` wrapper pattern and + the relevant `fb.add_trigger`, `fb.add_binding`, or setting method. +- Forward **every** explicit parameter to the binding, plus `**kwargs`. +- Parse `data_type` and other enum parameters using the shared helpers. +- Only add structured serialization when the extension contract requires it. + PR #330's `PromptArgument` list-to-JSON conversion is such a case; primitive + scalar binding fields do not need that special handling. + +### Runtime converters + +- Metadata-only argument changes do not automatically require converter changes. + Inspect the payload contract and existing converter first. +- For a genuinely new binding, add/register a converter in the runtime module + when required, import it from the package initializer for registration, and + test input annotations, decoding, encoding, and implicit-output behavior. +- If batching changes the runtime payload shape, confirm converter compatibility + rather than assuming a `cardinality` metadata change is sufficient. + +## 5. Write pydocs alongside the implementation + +Use the existing decorator docstring structure: + +- Explain what binding is added to `FunctionBuilder` and how it participates in + worker indexing / legacy function metadata. +- Describe triggering or input/output behavior and any relevant official reference. +- Document **every** argument with `:param snake_case_name:`, including + `arg_name`, `data_type`, and `kwargs`; include `:return: Decorator function.` +- Explain accepted enum/string forms, omission behavior, host-owned defaults, + conditional requirements, batching, and app-setting expressions where supported. +- Document new public enums, members, and helper objects. Keep documentation + accurate to the extension contract rather than inferring behavior from names. +- Follow repository formatting: four spaces and a 100-character maximum line + length, including imports and wrapped parameter descriptions. +- Check documentation stubs and examples for relevant public API updates; do not + modify unrelated or stale artifacts just because they exist. + +## 6. Add regression tests at both layers + +Extend the matching modules under `tests/decorators/` and `tests/`: + +1. Binding name, direction, minimal construction, and complete dictionary output. +2. Exact camelCase keys for explicit parameters and arbitrary snake_case kwargs. +3. Omitted `None` fields and preservation of meaningful falsy values. +4. Enum objects, case-insensitive string forms, invalid strings, and public exports. +5. Direct binding construction and decorator calls with string enum parameters. +6. Each conditionally required field missing individually, plus an error listing + all missing fields; other modes must work without those fields. +7. Both cardinalities where supported and omission of optional batching settings. +8. Existing positional calls, previous minimal usage, and unchanged kwargs support. +9. End-to-end metadata using `FunctionApp.get_functions()` and + `json.loads(function.get_function_json())`. Assert exact wire values and keys, + not only enum-valued dictionaries from `get_dict_repr()`. +10. Converter behavior when the converter or payload contract changes. + +Use `subTest` for case matrices where useful. Keep tests deterministic and avoid +requiring live Azure resources for decorator metadata verification. + +## 7. Validate and report + +- Configure/use the repository's selected Python environment; do not assume the + terminal's unqualified `python` is that interpreter. +- If dependencies are missing, install the declared development extra (`.[dev]`) + into the chosen environment; do not rewrite dependency constraints just to + work around a local missing package. +- Run focused binding/decorator/converter tests, then the full test suite. +- Use [test_code_quality.py](../../../tests/test_code_quality.py) as the source + of truth for lint and pydoc checks. It runs flake8 with the repository + [.flake8](../../../.flake8) configuration, ignores `D` for general lint, and + selects `D` for pydocs on the decorator API. Do not suppress new violations. +- Check editor/Pylance diagnostics, syntax, signature compatibility, and + `git diff --check`. A clean editor report does not replace runtime tests. +- Read actual terminal results and preserve failing test exit codes when chaining + commands; a successful trailing Git command must not mask failed tests. +- Review the final diff for unintended files, missing exports, undocumented + arguments, duplicate serialization logic, and compatibility regressions. +- Report the API change, validation behavior, test results, and any skipped or + unavailable checks. Do not claim live extension compatibility from SDK unit + tests alone. \ No newline at end of file diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index e267d450..928d5bbc 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -9,7 +9,7 @@ from ._http import HttpRequest, HttpResponse from .decorators import (FunctionApp, Function, Blueprint, DecoratorApi, DataType, AuthLevel, - Cardinality, AccessRights, HttpMethod, + Cardinality, AccessRights, HttpMethod, DeliveryMode, AsgiFunctionApp, WsgiFunctionApp, ExternalHttpFunctionApp, BlobSource, McpPropertyType, CosmosDBChangeFeedMode, PromptArgument) @@ -107,6 +107,7 @@ 'DataType', 'AuthLevel', 'Cardinality', + 'DeliveryMode', 'AccessRights', 'HttpMethod', 'BlobSource', diff --git a/azure/functions/decorators/__init__.py b/azure/functions/decorators/__init__.py index beaf7ff6..d9b11bbb 100644 --- a/azure/functions/decorators/__init__.py +++ b/azure/functions/decorators/__init__.py @@ -23,6 +23,7 @@ 'DataType', 'AuthLevel', 'Cardinality', + 'DeliveryMode', 'AccessRights', 'HttpMethod', 'BlobSource', diff --git a/azure/functions/decorators/connectors.py b/azure/functions/decorators/connectors.py index 995e6d23..38432043 100644 --- a/azure/functions/decorators/connectors.py +++ b/azure/functions/decorators/connectors.py @@ -1,9 +1,10 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from typing import Optional +from typing import Optional, Union from azure.functions.decorators.core import Trigger, \ - DataType + DataType, Cardinality, DeliveryMode +from azure.functions.decorators.utils import parse_singular_param_to_enum class ConnectorTrigger(Trigger): @@ -16,6 +17,37 @@ def get_binding_name(): def __init__(self, name: str, data_type: Optional[DataType] = None, + delivery_mode: Optional[Union[DeliveryMode, str]] = None, + connection: Optional[str] = None, + trigger_config_name: Optional[str] = None, + cardinality: Optional[Union[Cardinality, str]] = None, + max_batch_size: Optional[int] = None, + max_concurrent_calls: Optional[int] = None, + target_pending_event_threshold: Optional[int] = None, **kwargs): from azure.functions.decorators.constants import CONNECTOR_TRIGGER + delivery_mode = parse_singular_param_to_enum(delivery_mode, DeliveryMode) + cardinality = parse_singular_param_to_enum(cardinality, Cardinality) + if delivery_mode == DeliveryMode.POLL: + required_params = { + "connection": connection, + "trigger_config_name": trigger_config_name, + "max_batch_size": max_batch_size, + "max_concurrent_calls": max_concurrent_calls, + } + missing_params = [ + key for key, value in required_params.items() if value is None + ] + if missing_params: + raise ValueError( + "The following parameters are required for Poll delivery " + f"mode: {', '.join(missing_params)}") + + self.delivery_mode = delivery_mode + self.connection = connection + self.trigger_config_name = trigger_config_name + self.cardinality = cardinality + self.max_batch_size = max_batch_size + self.max_concurrent_calls = max_concurrent_calls + self.target_pending_event_threshold = target_pending_event_threshold super().__init__(name=name, data_type=data_type, type=CONNECTOR_TRIGGER) diff --git a/azure/functions/decorators/core.py b/azure/functions/decorators/core.py index cbd60b5f..9c6eb911 100644 --- a/azure/functions/decorators/core.py +++ b/azure/functions/decorators/core.py @@ -54,6 +54,18 @@ class Cardinality(StringifyEnum): """Multiple messaged passed to the function per invocation.""" +class DeliveryMode(StringifyEnum): + """Delivery mode for connector trigger bindings.""" + POLL = "Poll" + """Poll the connector for events.""" + WEBHOOK = "Webhook" + """Receive webhook events. The default when delivery mode is omitted.""" + + def __str__(self): + """Return the delivery mode value expected by the extension.""" + return self.value + + class AccessRights(StringifyEnum): """Access rights for the connection string. The default is manage, which indicates that the connection has the Manage permission. """ diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index fbe241b4..8b0ab1ef 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -17,7 +17,7 @@ from azure.functions.decorators.blob import BlobTrigger, BlobInput, BlobOutput from azure.functions.decorators.core import Binding, Trigger, DataType, \ AuthLevel, SCRIPT_FILE_NAME, Cardinality, AccessRights, Setting, BlobSource, \ - McpPropertyType, CosmosDBChangeFeedMode + McpPropertyType, CosmosDBChangeFeedMode, DeliveryMode from azure.functions.decorators.cosmosdb import CosmosDBTrigger, \ CosmosDBOutput, CosmosDBInput, CosmosDBTriggerV3, CosmosDBInputV3, \ CosmosDBOutputV3 @@ -1568,6 +1568,13 @@ def decorator(): def connector_trigger(self, arg_name: str, data_type: Optional[Union[DataType, str]] = None, + delivery_mode: Optional[Union[DeliveryMode, str]] = None, + connection: Optional[str] = None, + trigger_config_name: Optional[str] = None, + cardinality: Optional[Union[Cardinality, str]] = None, + max_batch_size: Optional[int] = None, + max_concurrent_calls: Optional[int] = None, + target_pending_event_threshold: Optional[int] = None, **kwargs) -> Callable[..., Any]: """ The `connector_trigger` decorator adds :class:`ConnectorTrigger` to the @@ -1584,6 +1591,20 @@ def connector_trigger(self, :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 delivery_mode: Poll or Webhook delivery mode. If omitted, the + function host defaults to Webhook. + :param connection: Connector connection setting. Required for Poll. + :param trigger_config_name: Connector trigger configuration name. + Required for Poll. Supports app setting expressions. + :param cardinality: Set to many to enable batching, equivalent to the + extension's IsBatched option. If omitted, a single event is passed + to the function. + :param max_batch_size: Maximum number of events per batch. + Required for Poll. + :param max_concurrent_calls: Maximum number of concurrent calls. + Required for Poll. + :param target_pending_event_threshold: Target number of pending events + per function instance used for scaling. :param kwargs: Keyword arguments for specifying additional binding fields to include in the binding JSON. @@ -1598,6 +1619,15 @@ def decorator(): name=arg_name, data_type=parse_singular_param_to_enum(data_type, DataType), + delivery_mode=parse_singular_param_to_enum( + delivery_mode, DeliveryMode), + connection=connection, + trigger_config_name=trigger_config_name, + cardinality=parse_singular_param_to_enum( + cardinality, Cardinality), + max_batch_size=max_batch_size, + max_concurrent_calls=max_concurrent_calls, + target_pending_event_threshold=target_pending_event_threshold, **kwargs)) return fb diff --git a/tests/decorators/test_connector.py b/tests/decorators/test_connector.py index 239e4861..f80c8b35 100644 --- a/tests/decorators/test_connector.py +++ b/tests/decorators/test_connector.py @@ -2,8 +2,11 @@ # Licensed under the MIT License. import unittest +import azure.functions as func +import azure.functions.decorators as decorators from azure.functions.decorators.constants import CONNECTOR_TRIGGER -from azure.functions.decorators.core import BindingDirection, DataType +from azure.functions.decorators.core import ( + BindingDirection, Cardinality, DataType, DeliveryMode) from azure.functions.decorators.connectors import ConnectorTrigger @@ -47,3 +50,126 @@ def test_connector_trigger_with_kwargs(self): self.assertEqual(dict_repr["dataType"], DataType.STRING) self.assertEqual(dict_repr["customProperty"], "custom_value") self.assertEqual(dict_repr["anotherField"], 123) + + def test_delivery_mode_public_exports(self): + self.assertIs(func.DeliveryMode, DeliveryMode) + self.assertIs(decorators.DeliveryMode, DeliveryMode) + self.assertIn("DeliveryMode", func.__all__) + self.assertIn("DeliveryMode", decorators.__all__) + self.assertEqual(DeliveryMode.POLL.value, "Poll") + self.assertEqual(DeliveryMode.WEBHOOK.value, "Webhook") + + def test_connector_trigger_poll_all_fields(self): + trigger = ConnectorTrigger( + name="payload", + delivery_mode=DeliveryMode.POLL, + connection="ConnectorNamespace", + trigger_config_name="%OnNewEmailTriggerConfigName%", + cardinality=Cardinality.MANY, + max_batch_size=10, + max_concurrent_calls=1, + target_pending_event_threshold=16, + custom_field="custom", + ) + + self.assertEqual(trigger.get_dict_repr(), { + "type": CONNECTOR_TRIGGER, + "direction": BindingDirection.IN, + "name": "payload", + "deliveryMode": DeliveryMode.POLL, + "connection": "ConnectorNamespace", + "triggerConfigName": "%OnNewEmailTriggerConfigName%", + "cardinality": Cardinality.MANY, + "maxBatchSize": 10, + "maxConcurrentCalls": 1, + "targetPendingEventThreshold": 16, + "customField": "custom", + }) + + def test_connector_trigger_poll_optional_fields_omitted(self): + trigger = ConnectorTrigger( + name="payload", delivery_mode=DeliveryMode.POLL, + connection="ConnectorNamespace", trigger_config_name="config", + max_batch_size=1, max_concurrent_calls=1, + ) + binding = trigger.get_dict_repr() + self.assertNotIn("cardinality", binding) + self.assertNotIn("targetPendingEventThreshold", binding) + self.assertNotIn("isBatched", binding) + + def test_connector_trigger_poll_requires_each_field(self): + required = { + "connection": "ConnectorNamespace", + "trigger_config_name": "config", + "max_batch_size": 1, + "max_concurrent_calls": 1, + } + for field in required: + with self.subTest(field=field): + params = dict(required) + params[field] = None + with self.assertRaisesRegex(ValueError, field): + ConnectorTrigger(name="payload", + delivery_mode=DeliveryMode.POLL, **params) + + def test_connector_trigger_poll_reports_all_missing_fields(self): + with self.assertRaisesRegex( + ValueError, "connection, trigger_config_name, " + "max_batch_size, max_concurrent_calls"): + ConnectorTrigger(name="payload", delivery_mode=DeliveryMode.POLL) + + def test_connector_trigger_webhook_requires_no_poll_fields(self): + trigger = ConnectorTrigger(name="payload", + delivery_mode=DeliveryMode.WEBHOOK) + self.assertEqual(trigger.get_dict_repr(), { + "type": CONNECTOR_TRIGGER, + "direction": BindingDirection.IN, + "name": "payload", + "deliveryMode": DeliveryMode.WEBHOOK, + }) + + def test_connector_trigger_preserves_positional_data_type(self): + trigger = ConnectorTrigger("payload", DataType.STRING) + self.assertEqual(trigger.get_dict_repr()["dataType"], DataType.STRING) + + def test_connector_trigger_preserves_zero_values(self): + trigger = ConnectorTrigger( + name="payload", max_batch_size=0, max_concurrent_calls=0, + target_pending_event_threshold=0, + ) + binding = trigger.get_dict_repr() + self.assertEqual(binding["maxBatchSize"], 0) + self.assertEqual(binding["maxConcurrentCalls"], 0) + self.assertEqual(binding["targetPendingEventThreshold"], 0) + + def test_connector_trigger_accepts_enum_strings(self): + for mode in ("Poll", "poll", "POLL"): + for cardinality in ("one", "ONE", "many", "MANY"): + with self.subTest(mode=mode, cardinality=cardinality): + trigger = ConnectorTrigger( + name="payload", delivery_mode=mode, + cardinality=cardinality, + connection="ConnectorNamespace", + trigger_config_name="config", + max_batch_size=1, max_concurrent_calls=1, + ) + binding = trigger.get_dict_repr() + self.assertIs(binding["deliveryMode"], DeliveryMode.POLL) + self.assertIs(binding["cardinality"], + Cardinality[cardinality.upper()]) + + def test_connector_trigger_accepts_webhook_strings(self): + for mode in ("Webhook", "webhook", "WEBHOOK"): + with self.subTest(mode=mode): + trigger = ConnectorTrigger(name="payload", delivery_mode=mode) + self.assertIs(trigger.delivery_mode, DeliveryMode.WEBHOOK) + + def test_connector_trigger_poll_string_requires_fields(self): + with self.assertRaisesRegex(ValueError, "connection"): + ConnectorTrigger(name="payload", delivery_mode="Poll") + + def test_connector_trigger_rejects_invalid_enum_strings(self): + with self.assertRaisesRegex(KeyError, "DeliveryMode"): + ConnectorTrigger(name="payload", delivery_mode="invalid") + with self.assertRaisesRegex(KeyError, "Cardinality"): + ConnectorTrigger(name="payload", cardinality="invalid") diff --git a/tests/test_connector.py b/tests/test_connector.py index 6260b2b5..0df4219d 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -163,3 +163,117 @@ def connector_with_kwargs(data): self.assertEqual(trigger_dict['type'], 'connectorTrigger') self.assertEqual(trigger_dict['customField'], 'custom_value') self.assertEqual(trigger_dict['anotherProperty'], 123) + + def test_decorator_poll_serializes_all_fields(self): + for mode in (func.DeliveryMode.POLL, "Poll", "poll", "POLL"): + for cardinality in (func.Cardinality.ONE, "one", + func.Cardinality.MANY, "many"): + with self.subTest(mode=mode, cardinality=cardinality): + app = func.FunctionApp() + + @app.connector_trigger( + arg_name="payload", + delivery_mode=mode, + connection="ConnectorNamespace", + trigger_config_name="%OnNewEmailTriggerConfigName%", + cardinality=cardinality, + max_batch_size=1, + max_concurrent_calls=1, + target_pending_event_threshold=16, + data_type="string", + custom_field="custom", + ) + def connector_function(payload): + return payload + + binding = json.loads( + app.get_functions()[0].get_function_json())['bindings'][0] + self.assertEqual(binding, { + "type": "connectorTrigger", + "direction": "IN", + "name": "payload", + "deliveryMode": "Poll", + "connection": "ConnectorNamespace", + "triggerConfigName": "%OnNewEmailTriggerConfigName%", + "cardinality": str(cardinality).upper(), + "maxBatchSize": 1, + "maxConcurrentCalls": 1, + "targetPendingEventThreshold": 16, + "dataType": "STRING", + "customField": "custom", + }) + + def test_decorator_webhook_and_default_require_no_poll_fields(self): + for mode in (None, func.DeliveryMode.WEBHOOK, "Webhook", "webhook"): + with self.subTest(mode=mode): + app = func.FunctionApp() + + @app.connector_trigger(arg_name="payload", delivery_mode=mode) + def connector_function(payload): + return payload + + binding = json.loads( + app.get_functions()[0].get_function_json())['bindings'][0] + expected = { + "type": "connectorTrigger", + "direction": "IN", + "name": "payload", + } + if mode is not None: + expected["deliveryMode"] = "Webhook" + self.assertEqual(binding, expected) + + def test_decorator_poll_requires_each_field(self): + required = { + "connection": "ConnectorNamespace", + "trigger_config_name": "config", + "max_batch_size": 1, + "max_concurrent_calls": 1, + } + for mode in (func.DeliveryMode.POLL, "Poll"): + for field in required: + with self.subTest(mode=mode, field=field): + app = func.FunctionApp() + params = dict(required) + del params[field] + with self.assertRaisesRegex(ValueError, field): + @app.connector_trigger(arg_name="payload", + delivery_mode=mode, **params) + def connector_function(payload): + return payload + + def test_decorator_invalid_enums(self): + for params, enum_name in (({"delivery_mode": "invalid"}, "DeliveryMode"), + ({"cardinality": "invalid"}, "Cardinality")): + with self.subTest(params=params): + app = func.FunctionApp() + with self.assertRaisesRegex(KeyError, enum_name): + @app.connector_trigger(arg_name="payload", **params) + def connector_function(payload): + return payload + + def test_decorator_preserves_positional_data_type(self): + app = func.FunctionApp() + + @app.connector_trigger("payload", func.DataType.STRING) + def connector_function(payload): + return payload + + trigger = app.get_functions()[0].get_trigger() + self.assertEqual(trigger.get_dict_repr()['dataType'], func.DataType.STRING) + + def test_decorator_poll_cardinality_is_optional(self): + app = func.FunctionApp() + + @app.connector_trigger( + arg_name="payload", delivery_mode=func.DeliveryMode.POLL, + connection="ConnectorNamespace", trigger_config_name="config", + max_batch_size=1, max_concurrent_calls=1, + ) + def connector_function(payload): + return payload + + binding = app.get_functions()[0].get_trigger().get_dict_repr() + self.assertNotIn("cardinality", binding) + self.assertNotIn("isBatched", binding) + self.assertNotIn("targetPendingEventThreshold", binding) From ea46bd90b7490c4244b6ba6b1850c44e4a3a6316 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 12 Oct 2026 14:55:24 -0500 Subject: [PATCH 2/5] fix: export DeliveryMode from decorators package --- azure/functions/decorators/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/decorators/__init__.py b/azure/functions/decorators/__init__.py index d9b11bbb..42c2b6e5 100644 --- a/azure/functions/decorators/__init__.py +++ b/azure/functions/decorators/__init__.py @@ -1,6 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. -from .core import Cardinality, AccessRights, CosmosDBChangeFeedMode +from .core import Cardinality, AccessRights, CosmosDBChangeFeedMode, DeliveryMode from .function_app import FunctionApp, Function, DecoratorApi, DataType, \ AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \ WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \ From 0388b35608730e35d3079332aea09f1bed581779 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 13 Oct 2026 13:20:54 -0500 Subject: [PATCH 3/5] Update required params, fix batch handling --- azure/functions/connectors.py | 41 +++++- azure/functions/decorators/connectors.py | 2 - azure/functions/decorators/function_app.py | 14 +- .../spec-functions-sdk-df-serialization.md | 0 tests/decorators/test_connector.py | 28 +++- tests/test_connector.py | 127 +++++++++++++++++- 6 files changed, 191 insertions(+), 21 deletions(-) rename spec-functions-sdk-df-serialization.md => docs/spec-functions-sdk-df-serialization.md (100%) diff --git a/azure/functions/connectors.py b/azure/functions/connectors.py index c72d9b7f..e7ec706e 100644 --- a/azure/functions/connectors.py +++ b/azure/functions/connectors.py @@ -1,6 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import json import typing from . import meta @@ -11,7 +12,11 @@ class ConnectorTriggerConverter(meta.InConverter, binding='connectorTrigger', @classmethod def check_input_type_annotation(cls, pytype: type) -> bool: - return issubclass(pytype, (str, dict, bytes)) + valid_types = (str, dict, bytes) + return ( + meta.is_iterable_type_annotation(pytype, valid_types) + or (isinstance(pytype, type) and issubclass(pytype, valid_types)) + ) @classmethod def has_implicit_output(cls) -> bool: @@ -21,8 +26,21 @@ def has_implicit_output(cls) -> bool: 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). + Returns scalar data unchanged or collection data as a list. """ + # Connector binding data has no Service Bus-style UserPropertiesArray. + # The host represents cardinality many with a collection or JSON array. + if data.type in ('collection_string', 'collection_bytes'): + return cls.decode_multiple_messages(data, trigger_metadata=trigger_metadata) + if data.type == 'json': + parsed_data = json.loads(data.value) if isinstance(data.value, str) else data.value + if isinstance(parsed_data, list): + return cls.decode_multiple_messages(data, trigger_metadata=trigger_metadata) + return cls.decode_single_message(data, trigger_metadata=trigger_metadata) + + @classmethod + def decode_single_message(cls, data: meta.Datum, *, trigger_metadata): + """Decode a single connector event, preserving the existing scalar format.""" # Handle different data types appropriately if data.type == 'json': # If it's already parsed JSON, use the value directly @@ -36,6 +54,24 @@ def decode(cls, data: meta.Datum, *, trigger_metadata): # Fallback to python_value for other types return data.python_value if hasattr(data, 'python_value') else data.value + @classmethod + def decode_multiple_messages( + cls, data: meta.Datum, *, trigger_metadata + ) -> typing.List[typing.Union[str, dict, bytes]]: + """Decode cardinality many payloads without parsing individual event strings.""" + if data.type == 'collection_string': + return list(data.value.string) + elif data.type == 'collection_bytes': + return list(data.value.bytes) + elif data.type == 'json': + parsed_data = json.loads(data.value) if isinstance(data.value, str) else data.value + if isinstance(parsed_data, list): + return parsed_data + raise ValueError('expected a JSON array for multiple connector messages') + else: + raise NotImplementedError('unable to decode multiple messages ' + f'with data type {data.type}') + @classmethod def encode(cls, obj: typing.Any, *, expected_type: typing.Optional[type] = None): """ @@ -48,7 +84,6 @@ def encode(cls, obj: typing.Any, *, expected_type: typing.Optional[type] = None) 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 diff --git a/azure/functions/decorators/connectors.py b/azure/functions/decorators/connectors.py index 38432043..3e3c1ccc 100644 --- a/azure/functions/decorators/connectors.py +++ b/azure/functions/decorators/connectors.py @@ -32,8 +32,6 @@ def __init__(self, required_params = { "connection": connection, "trigger_config_name": trigger_config_name, - "max_batch_size": max_batch_size, - "max_concurrent_calls": max_concurrent_calls, } missing_params = [ key for key, value in required_params.items() if value is None diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 8b0ab1ef..363415f8 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -1597,12 +1597,16 @@ def connector_trigger(self, :param trigger_config_name: Connector trigger configuration name. Required for Poll. Supports app setting expressions. :param cardinality: Set to many to enable batching, equivalent to the - extension's IsBatched option. If omitted, a single event is passed + extension's IsBatched option. Batch parameters may use typing.List[str] + or typing.List[bytes] annotations. If omitted, a single event is passed to the function. - :param max_batch_size: Maximum number of events per batch. - Required for Poll. - :param max_concurrent_calls: Maximum number of concurrent calls. - Required for Poll. + :param max_batch_size: Optional per-function override for the maximum + number of events per batch. If omitted, Poll delivery uses the + extension's app-wide host.json default. + :param max_concurrent_calls: Optional per-function override for the maximum + number of concurrent calls. If omitted, Poll delivery uses the + extension's app-wide host.json default for single-event invocations. + Batched invocations are processed one at a time by the extension. :param target_pending_event_threshold: Target number of pending events per function instance used for scaling. :param kwargs: Keyword arguments for specifying additional binding diff --git a/spec-functions-sdk-df-serialization.md b/docs/spec-functions-sdk-df-serialization.md similarity index 100% rename from spec-functions-sdk-df-serialization.md rename to docs/spec-functions-sdk-df-serialization.md diff --git a/tests/decorators/test_connector.py b/tests/decorators/test_connector.py index f80c8b35..ead0556f 100644 --- a/tests/decorators/test_connector.py +++ b/tests/decorators/test_connector.py @@ -90,9 +90,10 @@ def test_connector_trigger_poll_optional_fields_omitted(self): trigger = ConnectorTrigger( name="payload", delivery_mode=DeliveryMode.POLL, connection="ConnectorNamespace", trigger_config_name="config", - max_batch_size=1, max_concurrent_calls=1, ) binding = trigger.get_dict_repr() + self.assertNotIn("maxBatchSize", binding) + self.assertNotIn("maxConcurrentCalls", binding) self.assertNotIn("cardinality", binding) self.assertNotIn("targetPendingEventThreshold", binding) self.assertNotIn("isBatched", binding) @@ -101,8 +102,6 @@ def test_connector_trigger_poll_requires_each_field(self): required = { "connection": "ConnectorNamespace", "trigger_config_name": "config", - "max_batch_size": 1, - "max_concurrent_calls": 1, } for field in required: with self.subTest(field=field): @@ -114,10 +113,29 @@ def test_connector_trigger_poll_requires_each_field(self): def test_connector_trigger_poll_reports_all_missing_fields(self): with self.assertRaisesRegex( - ValueError, "connection, trigger_config_name, " - "max_batch_size, max_concurrent_calls"): + ValueError, "connection, trigger_config_name$"): ConnectorTrigger(name="payload", delivery_mode=DeliveryMode.POLL) + def test_connector_trigger_poll_independent_overrides(self): + for field, value, wire_name in ( + ("max_batch_size", 10, "maxBatchSize"), + ("max_concurrent_calls", 2, "maxConcurrentCalls")): + with self.subTest(field=field): + trigger = ConnectorTrigger( + name="payload", delivery_mode="Poll", + connection="ConnectorNamespace", trigger_config_name="config", + **{field: value}, + ) + self.assertEqual(trigger.get_dict_repr(), { + "type": CONNECTOR_TRIGGER, + "direction": BindingDirection.IN, + "name": "payload", + "deliveryMode": DeliveryMode.POLL, + "connection": "ConnectorNamespace", + "triggerConfigName": "config", + wire_name: value, + }) + def test_connector_trigger_webhook_requires_no_poll_fields(self): trigger = ConnectorTrigger(name="payload", delivery_mode=DeliveryMode.WEBHOOK) diff --git a/tests/test_connector.py b/tests/test_connector.py index 0df4219d..1aa17069 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -2,9 +2,11 @@ # Licensed under the MIT License. import unittest import json +from typing import Iterable, List, Sequence import azure.functions as func from azure.functions.meta import Datum from azure.functions.connectors import ConnectorTriggerConverter +from tests.utils.testutils import CollectionBytes, CollectionString class TestConnectorTriggerConverter(unittest.TestCase): @@ -16,13 +18,93 @@ def test_check_input_type_annotation_valid_types(self): 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 + self.assertFalse(ConnectorTriggerConverter.check_input_type_annotation(123)) class Dummy: pass self.assertFalse(ConnectorTriggerConverter.check_input_type_annotation(Dummy)) + def test_check_input_type_annotation_batch_types(self): + for annotation in (List[str], List[bytes], List[dict], + Iterable[str], Sequence[bytes]): + with self.subTest(annotation=annotation): + self.assertTrue( + ConnectorTriggerConverter.check_input_type_annotation(annotation)) + + def test_check_input_type_annotation_unsupported_batch_types(self): + for annotation in (List[int], List[float], Iterable[int], List, list): + with self.subTest(annotation=annotation): + self.assertFalse( + ConnectorTriggerConverter.check_input_type_annotation(annotation)) + + def test_decode_batches_with_valid_annotations(self): + for annotation, data_type, collection, values in ( + (List[str], 'collection_string', CollectionString, + ['{"id": "first"}', '{"id": "second"}']), + (List[bytes], 'collection_bytes', CollectionBytes, + [b'first', b'second'])): + for batch in (values, []): + with self.subTest(annotation=annotation, batch=batch): + self.assertTrue( + ConnectorTriggerConverter.check_input_type_annotation(annotation)) + data = Datum(type=data_type, value=collection(batch)) + result = ConnectorTriggerConverter.decode(data, trigger_metadata={}) + self.assertIsInstance(result, list) + self.assertEqual(result, batch) + + def test_decode_json_batch_preserves_parsed_values(self): + values = [{'id': 'first'}, {'id': 'second'}] + self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(List[dict])) + result = ConnectorTriggerConverter.decode( + Datum(type='json', value=values), trigger_metadata={}) + self.assertEqual(result, values) + + def test_decode_serialized_json_batches(self): + for values in ([{'id': 'first'}, {'id': 'second'}], + ['{"id": "first"}', '{"id": "second"}'], []): + with self.subTest(values=values): + data = Datum(type='json', value=json.dumps(values)) + self.assertEqual( + ConnectorTriggerConverter.decode(data, trigger_metadata={}), values) + self.assertEqual( + ConnectorTriggerConverter.decode_multiple_messages( + data, trigger_metadata={}), values) + + def test_decode_multiple_messages_collections(self): + for data_type, collection, values in ( + ('collection_string', CollectionString, ['first', 'second']), + ('collection_bytes', CollectionBytes, [b'first', b'second'])): + for batch in (values, []): + with self.subTest(data_type=data_type, batch=batch): + result = ConnectorTriggerConverter.decode_multiple_messages( + Datum(type=data_type, value=collection(batch)), trigger_metadata={}) + self.assertIsInstance(result, list) + self.assertEqual(result, batch) + + def test_decode_multiple_messages_rejects_non_array_json(self): + for value in ({'id': 'single'}, '{"id": "single"}', 'null', '42'): + with self.subTest(value=value): + with self.assertRaisesRegex(ValueError, 'expected a JSON array'): + ConnectorTriggerConverter.decode_multiple_messages( + Datum(type='json', value=value), trigger_metadata={}) + + def test_decode_multiple_messages_unsupported_type(self): + with self.assertRaisesRegex(NotImplementedError, 'data type string'): + ConnectorTriggerConverter.decode_multiple_messages( + Datum(type='string', value='single'), trigger_metadata={}) + + def test_decode_single_message_preserves_scalar_formats(self): + for data_type, value in (('string', 'hello'), ('bytes', b'hello'), + ('json', {'id': 'single'}), + ('json', '{"id": "single"}')): + with self.subTest(data_type=data_type, value=value): + data = Datum(type=data_type, value=value) + self.assertEqual( + ConnectorTriggerConverter.decode_single_message( + data, trigger_metadata={}), value) + self.assertEqual( + ConnectorTriggerConverter.decode(data, trigger_metadata={}), value) + def test_has_implicit_output(self): self.assertTrue(ConnectorTriggerConverter.has_implicit_output()) @@ -227,8 +309,6 @@ def test_decorator_poll_requires_each_field(self): required = { "connection": "ConnectorNamespace", "trigger_config_name": "config", - "max_batch_size": 1, - "max_concurrent_calls": 1, } for mode in (func.DeliveryMode.POLL, "Poll"): for field in required: @@ -262,18 +342,53 @@ def connector_function(payload): trigger = app.get_functions()[0].get_trigger() self.assertEqual(trigger.get_dict_repr()['dataType'], func.DataType.STRING) - def test_decorator_poll_cardinality_is_optional(self): + def test_decorator_poll_optional_fields_are_omitted(self): app = func.FunctionApp() @app.connector_trigger( arg_name="payload", delivery_mode=func.DeliveryMode.POLL, connection="ConnectorNamespace", trigger_config_name="config", - max_batch_size=1, max_concurrent_calls=1, ) def connector_function(payload): return payload binding = app.get_functions()[0].get_trigger().get_dict_repr() + self.assertNotIn("maxBatchSize", binding) + self.assertNotIn("maxConcurrentCalls", binding) self.assertNotIn("cardinality", binding) self.assertNotIn("isBatched", binding) self.assertNotIn("targetPendingEventThreshold", binding) + + def test_decorator_poll_uses_host_defaults_with_batch_annotation(self): + for mode in (func.DeliveryMode.POLL, "poll"): + with self.subTest(mode=mode): + app = func.FunctionApp() + + @app.connector_trigger( + arg_name="payload", delivery_mode=mode, + connection="ConnectorNamespace", trigger_config_name="config", + cardinality=func.Cardinality.MANY, + ) + def connector_function(payload: List[str]): + return payload + + built_func = app.get_functions()[0] + binding = json.loads(built_func.get_function_json())['bindings'][0] + self.assertEqual(binding, { + "type": "connectorTrigger", + "direction": "IN", + "name": "payload", + "deliveryMode": "Poll", + "connection": "ConnectorNamespace", + "triggerConfigName": "config", + "cardinality": "MANY", + }) + annotation = built_func.get_user_function().__annotations__['payload'] + self.assertTrue( + ConnectorTriggerConverter.check_input_type_annotation(annotation)) + values = ['first', 'second'] + decoded = ConnectorTriggerConverter.decode( + Datum(type='collection_string', value=CollectionString(values)), + trigger_metadata={}, + ) + self.assertEqual(built_func.get_user_function()(decoded), values) From 0f5fce7039e969e217c2bec6dae9161f6ea0030f Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 13 Oct 2026 15:57:42 -0500 Subject: [PATCH 4/5] change f-string to lazy logging, remove comments --- azure/functions/connectors.py | 1 - azure/functions/decorators/connectors.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/azure/functions/connectors.py b/azure/functions/connectors.py index e7ec706e..af59befd 100644 --- a/azure/functions/connectors.py +++ b/azure/functions/connectors.py @@ -28,7 +28,6 @@ def decode(cls, data: meta.Datum, *, trigger_metadata): Decode incoming connector trigger request data. Returns scalar data unchanged or collection data as a list. """ - # Connector binding data has no Service Bus-style UserPropertiesArray. # The host represents cardinality many with a collection or JSON array. if data.type in ('collection_string', 'collection_bytes'): return cls.decode_multiple_messages(data, trigger_metadata=trigger_metadata) diff --git a/azure/functions/decorators/connectors.py b/azure/functions/decorators/connectors.py index 3e3c1ccc..f63af149 100644 --- a/azure/functions/decorators/connectors.py +++ b/azure/functions/decorators/connectors.py @@ -39,7 +39,7 @@ def __init__(self, if missing_params: raise ValueError( "The following parameters are required for Poll delivery " - f"mode: {', '.join(missing_params)}") + "mode: %s" % ', '.join(missing_params)) self.delivery_mode = delivery_mode self.connection = connection From ee5759e5f91911b25f80755c4cba7ff122e40695 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Thu, 15 Oct 2026 15:18:41 -0500 Subject: [PATCH 5/5] feedback --- azure/functions/connectors.py | 6 +----- tests/test_connector.py | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/azure/functions/connectors.py b/azure/functions/connectors.py index af59befd..5c122614 100644 --- a/azure/functions/connectors.py +++ b/azure/functions/connectors.py @@ -28,13 +28,9 @@ def decode(cls, data: meta.Datum, *, trigger_metadata): Decode incoming connector trigger request data. Returns scalar data unchanged or collection data as a list. """ - # The host represents cardinality many with a collection or JSON array. + # Only explicit collection types identify batches; JSON arrays can be single events. if data.type in ('collection_string', 'collection_bytes'): return cls.decode_multiple_messages(data, trigger_metadata=trigger_metadata) - if data.type == 'json': - parsed_data = json.loads(data.value) if isinstance(data.value, str) else data.value - if isinstance(parsed_data, list): - return cls.decode_multiple_messages(data, trigger_metadata=trigger_metadata) return cls.decode_single_message(data, trigger_metadata=trigger_metadata) @classmethod diff --git a/tests/test_connector.py b/tests/test_connector.py index 1aa17069..52f2f39e 100644 --- a/tests/test_connector.py +++ b/tests/test_connector.py @@ -52,24 +52,35 @@ def test_decode_batches_with_valid_annotations(self): self.assertIsInstance(result, list) self.assertEqual(result, batch) - def test_decode_json_batch_preserves_parsed_values(self): + def test_decode_json_array_preserves_parsed_values(self): values = [{'id': 'first'}, {'id': 'second'}] self.assertTrue(ConnectorTriggerConverter.check_input_type_annotation(List[dict])) result = ConnectorTriggerConverter.decode( Datum(type='json', value=values), trigger_metadata={}) - self.assertEqual(result, values) + self.assertIs(result, values) - def test_decode_serialized_json_batches(self): + def test_decode_serialized_json_arrays_preserve_scalar_payload(self): for values in ([{'id': 'first'}, {'id': 'second'}], ['{"id": "first"}', '{"id": "second"}'], []): with self.subTest(values=values): data = Datum(type='json', value=json.dumps(values)) self.assertEqual( - ConnectorTriggerConverter.decode(data, trigger_metadata={}), values) + ConnectorTriggerConverter.decode(data, trigger_metadata={}), data.value) + + def test_decode_multiple_messages_serialized_json_batches(self): + for values in ([{'id': 'first'}, {'id': 'second'}], + ['{"id": "first"}', '{"id": "second"}'], []): + with self.subTest(values=values): + data = Datum(type='json', value=json.dumps(values)) self.assertEqual( ConnectorTriggerConverter.decode_multiple_messages( data, trigger_metadata={}), values) + def test_decode_invalid_json_preserves_scalar_payload(self): + data = Datum(type='json', value='[not valid JSON') + self.assertEqual( + ConnectorTriggerConverter.decode(data, trigger_metadata={}), data.value) + def test_decode_multiple_messages_collections(self): for data_type, collection, values in ( ('collection_string', CollectionString, ['first', 'second']),