Skip to content

Commit 4283260

Browse files
hallvictoriaVictoria Hall
and
Victoria Hall
authored
feat: support ServiceBus SDK-type bindings (#107)
* Initial changes to support sbrm * missed return * add test pipeline, start readme * add to pipelines, update tests, lint * lint, samples, fix pyproject * move to pydocs * Update readme, final samples * lower version * feedback + run all tests as part of public build * feedback --------- Co-authored-by: Victoria Hall <[email protected]>
1 parent 4d070f4 commit 4283260

29 files changed

+953
-0
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
Copyright (c) Microsoft Corporation.
2+
3+
MIT License
4+
5+
Permission is hereby granted, free of charge, to any person obtaining a copy
6+
of this software and associated documentation files (the "Software"), to deal
7+
in the Software without restriction, including without limitation the rights
8+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9+
copies of the Software, and to permit persons to whom the Software is
10+
furnished to do so, subject to the following conditions:
11+
12+
The above copyright notice and this permission notice shall be included in all
13+
copies or substantial portions of the Software.
14+
15+
THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21+
SOFTWARE.
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
recursive-include azure *.py *.pyi
2+
recursive-include tests *.py
3+
include LICENSE README.md
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Azure Functions Extensions Bindings ServiceBus library for Python
2+
This library allows ServiceBus Triggers in Python Function Apps to recognize and bind to client types from the
3+
Azure ServiceBus sdk.
4+
5+
The SDK types can be generated from:
6+
7+
* ServiceBus Triggers
8+
9+
The supported ServiceBus SDK types include:
10+
11+
* ServiceBusReceivedMessage
12+
13+
[Source code](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-servicebus)
14+
|
15+
[Package (PyPi)](https://pypi.org/project/azurefunctions-extensions-bindings-servicebus/)
16+
| [Samples](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-servicebus/samples)
17+
18+
19+
## Getting started
20+
21+
### Prerequisites
22+
* Python 3.9 or later is required to use this package. For more details, please read our page on [Python Functions version support policy](https://learn.microsoft.com/en-us/azure/azure-functions/functions-versions?tabs=isolated-process%2Cv4&pivots=programming-language-python#languages).
23+
24+
* You must have an [Azure subscription](https://azure.microsoft.com/free/) and a
25+
[ServiceBus Resource](https://learn.microsoft.com/en-us/azure/azure-functions/functions-bindings-service-bus?tabs=isolated-process%2Cextensionv5%2Cextensionv3&pivots=programming-language-python) to use this package.
26+
27+
### Install the package
28+
Install the Azure Functions Extensions Bindings ServiceBus library for Python with pip:
29+
30+
```bash
31+
pip install azurefunctions-extensions-bindings-servicebus
32+
```
33+
34+
35+
### Bind to the SDK-type
36+
The Azure Functions Extensions Bindings ServiceBus library for Python allows you to create a function app with a ServiceBus Trigger
37+
and define the type as a ServiceBusReceivedMessage. Instead of receiving
38+
a ServiceBusMessage, when the function is executed, the type returned will be the defined SDK-type and have all the
39+
properties and methods available as seen in the Azure ServiceBus library for Python.
40+
41+
42+
```python
43+
import logging
44+
import azure.functions as func
45+
import azurefunctions.extensions.bindings.servicebus as servicebus
46+
47+
app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION)
48+
49+
@app.service_bus_queue_trigger(arg_name="receivedmessage",
50+
queue_name="QUEUE_NAME",
51+
connection="SERVICEBUS_CONNECTION")
52+
def servicebus_queue_trigger(receivedmessage: servicebus.ServiceBusReceivedMessage):
53+
logging.info("Python ServiceBus queue trigger processed message.")
54+
logging.info("Receiving: %s\n"
55+
"Body: %s\n"
56+
"Enqueued time: %s\n"
57+
"Lock Token: %s\n"
58+
"Locked until : %s\n"
59+
"Message ID: %s\n"
60+
"Sequence number: %s\n",
61+
receivedmessage,
62+
receivedmessage.body,
63+
receivedmessage.enqueued_time_utc,
64+
receivedmessage.lock_token,
65+
receivedmessage.locked_until,
66+
receivedmessage.message_id,
67+
receivedmessage.sequence_number)
68+
```
69+
70+
## Troubleshooting
71+
### General
72+
The SDK-types raise exceptions defined in [Azure Core](https://github.com/Azure/azure-sdk-for-python/blob/main/sdk/core/azure-core/README.md).
73+
74+
This list can be used for reference to catch thrown exceptions. To get the specific error code of the exception, use the `error_code` attribute, i.e, `exception.error_code`.
75+
76+
## Next steps
77+
78+
### More sample code
79+
80+
Get started with our [ServiceBus samples](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-servicebus/samples).
81+
82+
Several samples are available in this GitHub repository. These samples provide example code for additional scenarios commonly encountered while working with Azure ServiceBus:
83+
84+
* [servicebus_samples_single](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-servicebus/samples/servicebus_samples_single) - Examples for using the ServiceBusReceivedMessage type:
85+
* From ServiceBus Queue Trigger (Single Message)
86+
* From ServiceBus Topic Trigger (Single Message)
87+
88+
* [servicebus_samples_batch](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-servicebus/samples/service_samples_batch) - Examples for interacting with batches:
89+
* From ServiceBus Queue Trigger (Batch)
90+
* From ServiceBus Topic Trigger (Batch)
91+
92+
93+
### Additional documentation
94+
For more information on the Azure ServiceBus SDK, see the [Azure ServiceBus SDK documentation](https://learn.microsoft.com/en-us/python/api/overview/azure/servicebus-readme?view=azure-python) on docs.microsoft.com
95+
and the [Azure ServiceBus README](https://github.com/Azure/azure-sdk-for-python/blob/azure-servicebus_7.14.1/sdk/servicebus/azure-servicebus/README.md).
96+
97+
## Contributing
98+
This project welcomes contributions and suggestions. Most contributions require you to agree to a Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us the rights to use your contribution. For details, visit https://cla.microsoft.com.
99+
100+
When you submit a pull request, a CLA-bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repos using our CLA.
101+
102+
This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [[email protected]](mailto:[email protected]) with any additional questions or comments.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from .serviceBusReceivedMessage import ServiceBusReceivedMessage
5+
from .serviceBusConverter import ServiceBusConverter
6+
7+
__all__ = [
8+
"ServiceBusReceivedMessage",
9+
"ServiceBusConverter",
10+
]
11+
12+
__version__ = '1.0.0a1'
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import collections.abc
5+
from typing import Any, get_args, get_origin
6+
7+
from azurefunctions.extensions.base import Datum, InConverter
8+
from .serviceBusReceivedMessage import ServiceBusReceivedMessage
9+
10+
11+
class ServiceBusConverter(
12+
InConverter,
13+
binding='serviceBusTrigger', trigger=True
14+
):
15+
@classmethod
16+
def check_input_type_annotation(cls, pytype: type) -> bool:
17+
if pytype is None:
18+
return False
19+
20+
# The annotation is a class/type (not an object) - not iterable
21+
if (isinstance(pytype, type)
22+
and issubclass(pytype, ServiceBusReceivedMessage)):
23+
return True
24+
25+
# An iterable who only has one inner type and is a subclass of SdkType
26+
return cls._is_iterable_supported_type(pytype)
27+
28+
@classmethod
29+
def _is_iterable_supported_type(cls, annotation: type) -> bool:
30+
# Check base type from type hint. Ex: List from List[SdkType]
31+
base_type = get_origin(annotation)
32+
if (base_type is None
33+
or not issubclass(base_type, collections.abc.Iterable)):
34+
return False
35+
36+
inner_types = get_args(annotation)
37+
if inner_types is None or len(inner_types) != 1:
38+
return False
39+
40+
inner_type = inner_types[0]
41+
42+
return (isinstance(inner_type, type)
43+
and issubclass(inner_type, ServiceBusReceivedMessage))
44+
45+
@classmethod
46+
def decode(cls, data: Datum, *, trigger_metadata, pytype) -> Any:
47+
"""
48+
ServiceBus allows for batches to be sent. The cardinality can be one or many.
49+
When the cardinality is one:
50+
- The data is of type "model_binding_data" - each event is an independent
51+
function invocation
52+
When the cardinality is many:
53+
- The data is of type "collection_model_binding_data" - all events are sent
54+
in a single function invocation
55+
- collection_model_binding_data has 1 or more model_binding_data objects
56+
"""
57+
if data is None or data.type is None:
58+
return None
59+
60+
data_type = data.type
61+
62+
if data_type == "model_binding_data":
63+
return ServiceBusReceivedMessage(data=data.value).get_sdk_type()
64+
elif data_type == "collection_model_binding_data":
65+
try:
66+
return [ServiceBusReceivedMessage(data=mbd).get_sdk_type()
67+
for mbd in data.value.model_binding_data]
68+
except Exception as e:
69+
raise ValueError("Failed to decode incoming ServiceBus batch: "
70+
+ repr(e)) from e
71+
else:
72+
raise ValueError(
73+
"Unexpected type of data received for the 'servicebus' binding: "
74+
+ repr(data.type))
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
from azure.servicebus import ServiceBusReceivedMessage as ServiceBusReceivedMessageSdk
5+
from azurefunctions.extensions.base import Datum, SdkType
6+
from .utils import get_decoded_message
7+
8+
9+
class ServiceBusReceivedMessage(SdkType):
10+
def __init__(self, *, data: Datum) -> None:
11+
# model_binding_data properties
12+
self._data = data
13+
self._version = None
14+
self._source = None
15+
self._content_type = None
16+
self._content = None
17+
self._decoded_message = None
18+
if self._data:
19+
self._version = data.version
20+
self._source = data.source
21+
self._content_type = data.content_type
22+
self._content = data.content
23+
self._decoded_message = get_decoded_message(self._content)
24+
25+
def get_sdk_type(self):
26+
"""
27+
Returns a ServiceBusReceivedMessage.
28+
Message settling is not yet supported.
29+
"""
30+
if self._decoded_message:
31+
return ServiceBusReceivedMessageSdk(self._decoded_message, receiver=None)
32+
else:
33+
return None
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright (c) Microsoft Corporation. All rights reserved.
2+
# Licensed under the MIT License.
3+
4+
import uamqp
5+
import uuid
6+
7+
8+
_X_OPT_LOCK_TOKEN = b"x-opt-lock-token"
9+
10+
11+
def get_lock_token(message: bytes, index: int) -> str:
12+
# Get the lock token from the message
13+
lock_token_encoded = message[:index]
14+
15+
# Convert the lock token to a UUID using the first 16 bytes
16+
lock_token_uuid = uuid.UUID(bytes=lock_token_encoded[:16])
17+
18+
return lock_token_uuid
19+
20+
21+
def get_amqp_message(message: bytes, index: int):
22+
"""
23+
Get the amqp message from the model_binding_data content
24+
and create the message.
25+
"""
26+
amqp_message = message[index + len(_X_OPT_LOCK_TOKEN):]
27+
decoded_message = uamqp.Message().decode_from_bytes(amqp_message)
28+
29+
return decoded_message
30+
31+
32+
def get_decoded_message(content: bytes):
33+
"""
34+
First, find the end of the lock token. Then,
35+
get the lock token UUID and create the delivery
36+
annotations dictionary. Finally, get the amqp message
37+
and set the delivery annotations. Once the delivery
38+
annotations have been set, the amqp message is ready to
39+
return.
40+
"""
41+
if content:
42+
try:
43+
index = content.find(_X_OPT_LOCK_TOKEN)
44+
45+
lock_token = get_lock_token(content, index)
46+
delivery_anno_dict = {_X_OPT_LOCK_TOKEN: lock_token}
47+
48+
decoded_message = get_amqp_message(content, index)
49+
decoded_message.delivery_annotations = delivery_anno_dict
50+
return decoded_message
51+
except Exception as e:
52+
raise ValueError(f"Failed to decode ServiceBus content: {e}") from e
53+
return None
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
[build-system]
2+
requires = ["setuptools >= 61.0"]
3+
build-backend = "setuptools.build_meta"
4+
5+
[project]
6+
name = "azurefunctions-extensions-bindings-servicebus"
7+
dynamic = ["version"]
8+
requires-python = ">=3.9"
9+
authors = [{ name = "Azure Functions team at Microsoft Corp.", email = "[email protected]"}]
10+
description = "ServiceBus Python worker extension for Azure Functions."
11+
readme = "README.md"
12+
license = {text = "MIT License"}
13+
classifiers= [
14+
'License :: OSI Approved :: MIT License',
15+
'Intended Audience :: Developers',
16+
'Programming Language :: Python :: 3',
17+
'Programming Language :: Python :: 3.9',
18+
'Programming Language :: Python :: 3.10',
19+
'Programming Language :: Python :: 3.11',
20+
'Programming Language :: Python :: 3.12',
21+
'Programming Language :: Python :: 3.13',
22+
'Operating System :: Microsoft :: Windows',
23+
'Operating System :: POSIX',
24+
'Operating System :: MacOS :: MacOS X',
25+
'Environment :: Web Environment',
26+
'Development Status :: 5 - Production/Stable',
27+
]
28+
dependencies = [
29+
'azurefunctions-extensions-base',
30+
'azure-servicebus~=7.14.2',
31+
'uamqp~=1.6.11'
32+
]
33+
34+
[project.optional-dependencies]
35+
dev = [
36+
'flake8',
37+
'mypy',
38+
'pytest',
39+
'pytest-cov',
40+
'coverage',
41+
'pytest-instafail',
42+
'pre-commit'
43+
]
44+
45+
[tool.setuptools.dynamic]
46+
version = {attr = "azurefunctions.extensions.bindings.servicebus.__version__"}
47+
48+
[tool.setuptools.packages.find]
49+
exclude = [
50+
'azurefunctions.extensions.bindings','azurefunctions.extensions',
51+
'azurefunctions', 'tests', 'samples'
52+
]

0 commit comments

Comments
 (0)