diff --git a/azurefunctions-extensions-bindings-blob/README.md b/azurefunctions-extensions-bindings-blob/README.md index 447bd21..eb40c8b 100644 --- a/azurefunctions-extensions-bindings-blob/README.md +++ b/azurefunctions-extensions-bindings-blob/README.md @@ -56,7 +56,7 @@ import logging import azure.functions as func import azurefunctions.extensions.bindings.blob as blob -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) @app.blob_trigger(arg_name="client", path="PATH/TO/BLOB", diff --git a/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/blobClient.py b/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/blobClient.py index b985d84..c5a2bdf 100644 --- a/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/blobClient.py +++ b/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/blobClient.py @@ -56,4 +56,4 @@ def get_sdk_type(self): blob=self._blobName, ) else: - return None + raise ValueError(f"Unable to create {self.__class__.__name__} SDK type.") diff --git a/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/containerClient.py b/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/containerClient.py index 7de6679..8f83bd4 100644 --- a/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/containerClient.py +++ b/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/containerClient.py @@ -46,4 +46,4 @@ def get_sdk_type(self): container=self._containerName ) else: - return None + raise ValueError(f"Unable to create {self.__class__.__name__} SDK type.") diff --git a/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/storageStreamDownloader.py b/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/storageStreamDownloader.py index 2aa254b..a21c3ca 100644 --- a/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/storageStreamDownloader.py +++ b/azurefunctions-extensions-bindings-blob/azurefunctions/extensions/bindings/blob/storageStreamDownloader.py @@ -48,4 +48,4 @@ def get_sdk_type(self): blob=self._blobName, ).download_blob() else: - return None + raise ValueError(f"Unable to create {self.__class__.__name__} SDK type.") diff --git a/azurefunctions-extensions-bindings-blob/samples/blob_samples_blobclient/function_app.py b/azurefunctions-extensions-bindings-blob/samples/blob_samples_blobclient/function_app.py index 1fd36ef..4287403 100644 --- a/azurefunctions-extensions-bindings-blob/samples/blob_samples_blobclient/function_app.py +++ b/azurefunctions-extensions-bindings-blob/samples/blob_samples_blobclient/function_app.py @@ -9,7 +9,7 @@ import azure.functions as func import azurefunctions.extensions.bindings.blob as blob -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) """ FOLDER: blob_samples_blobclient diff --git a/azurefunctions-extensions-bindings-blob/samples/blob_samples_containerclient/function_app.py b/azurefunctions-extensions-bindings-blob/samples/blob_samples_containerclient/function_app.py index 2b63d7a..5205ed8 100644 --- a/azurefunctions-extensions-bindings-blob/samples/blob_samples_containerclient/function_app.py +++ b/azurefunctions-extensions-bindings-blob/samples/blob_samples_containerclient/function_app.py @@ -9,7 +9,7 @@ import azure.functions as func import azurefunctions.extensions.bindings.blob as blob -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) """ FOLDER: blob_samples_containerclient diff --git a/azurefunctions-extensions-bindings-blob/samples/blob_samples_storagestreamdownloader/function_app.py b/azurefunctions-extensions-bindings-blob/samples/blob_samples_storagestreamdownloader/function_app.py index e029731..51b22ae 100644 --- a/azurefunctions-extensions-bindings-blob/samples/blob_samples_storagestreamdownloader/function_app.py +++ b/azurefunctions-extensions-bindings-blob/samples/blob_samples_storagestreamdownloader/function_app.py @@ -9,7 +9,7 @@ import azure.functions as func import azurefunctions.extensions.bindings.blob as blob -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) """ FOLDER: blob_samples_storagestreamdownloader diff --git a/azurefunctions-extensions-bindings-blob/tests/test_blobclient.py b/azurefunctions-extensions-bindings-blob/tests/test_blobclient.py index ca3782d..68b66aa 100644 --- a/azurefunctions-extensions-bindings-blob/tests/test_blobclient.py +++ b/azurefunctions-extensions-bindings-blob/tests/test_blobclient.py @@ -94,10 +94,10 @@ def test_input_incorrect_type(self): def test_input_empty(self): datum: Datum = Datum(value={}, type="model_binding_data") - result: BlobClient = BlobClientConverter.decode( - data=datum, trigger_metadata=None, pytype=BlobClient - ) - self.assertIsNone(result) + with self.assertRaises(ValueError): + BlobClientConverter.decode( + data=datum, trigger_metadata=None, pytype=BlobClient + ) def test_input_populated(self): content = { diff --git a/azurefunctions-extensions-bindings-blob/tests/test_containerclient.py b/azurefunctions-extensions-bindings-blob/tests/test_containerclient.py index ad9d801..2f7c0e1 100644 --- a/azurefunctions-extensions-bindings-blob/tests/test_containerclient.py +++ b/azurefunctions-extensions-bindings-blob/tests/test_containerclient.py @@ -94,10 +94,10 @@ def test_input_incorrect_type(self): def test_input_empty(self): datum: Datum = Datum(value={}, type="model_binding_data") - result: ContainerClient = BlobClientConverter.decode( - data=datum, trigger_metadata=None, pytype=ContainerClient - ) - self.assertIsNone(result) + with self.assertRaises(ValueError): + BlobClientConverter.decode( + data=datum, trigger_metadata=None, pytype=ContainerClient + ) def test_input_populated(self): content = { diff --git a/azurefunctions-extensions-bindings-blob/tests/test_ssd.py b/azurefunctions-extensions-bindings-blob/tests/test_ssd.py index fbd16bc..494a8fa 100644 --- a/azurefunctions-extensions-bindings-blob/tests/test_ssd.py +++ b/azurefunctions-extensions-bindings-blob/tests/test_ssd.py @@ -97,10 +97,10 @@ def test_input_incorrect_type(self): def test_input_empty(self): datum: Datum = Datum(value={}, type="model_binding_data") - result: StorageStreamDownloader = BlobClientConverter.decode( - data=datum, trigger_metadata=None, pytype=StorageStreamDownloader - ) - self.assertIsNone(result) + with self.assertRaises(ValueError): + BlobClientConverter.decode( + data=datum, trigger_metadata=None, pytype=StorageStreamDownloader + ) def test_input_populated(self): content = { diff --git a/azurefunctions-extensions-bindings-cosmosdb/LICENSE b/azurefunctions-extensions-bindings-cosmosdb/LICENSE new file mode 100644 index 0000000..63447fd --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) Microsoft Corporation. + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/MANIFEST.in b/azurefunctions-extensions-bindings-cosmosdb/MANIFEST.in new file mode 100644 index 0000000..e1ae5ad --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/MANIFEST.in @@ -0,0 +1,3 @@ +recursive-include azure *.py *.pyi +recursive-include tests *.py +include LICENSE README.md \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/README.md b/azurefunctions-extensions-bindings-cosmosdb/README.md new file mode 100644 index 0000000..176292d --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/README.md @@ -0,0 +1,103 @@ +# Azure Functions Extensions Bindings Cosmos DB library for Python +This library allows Cosmos DB Input bindings in Python Function Apps to recognize and bind to client types from the +Azure Cosmos DB SDK. + +Cosmos DB client types can be generated from: + +* Cosmos DB Input + +[Source code](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-cosmosdb) +[Package (PyPi)](https://pypi.org/project/azurefunctions-extensions-bindings-cosmosdb/) +| [Samples](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-cosmosdb/samples) + + +## Getting started + +### Prerequisites +* 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). + +* You must have an [Azure subscription](https://azure.microsoft.com/free/) and an +[Azure storage account](https://docs.microsoft.com/azure/storage/common/storage-account-overview) to use this package. + +### Install the package +Install the Azure Functions Extensions Bindings Cosmos DB library for Python with pip: + +```bash +pip install azurefunctions-extensions-bindings-cosmosdb +``` + +### Create a storage account +If you wish to create a new storage account, you can use the +[Azure Portal](https://docs.microsoft.com/azure/storage/common/storage-quickstart-create-account?tabs=azure-portal), +[Azure PowerShell](https://docs.microsoft.com/azure/storage/common/storage-quickstart-create-account?tabs=azure-powershell), +or [Azure CLI](https://docs.microsoft.com/azure/storage/common/storage-quickstart-create-account?tabs=azure-cli): + +```bash +# Create a new resource group to hold the storage account - +# if using an existing resource group, skip this step +az group create --name my-resource-group --location westus2 + +# Create the storage account +az storage account create -n my-storage-account-name -g my-resource-group +``` + +### Bind to the SDK-type +The Azure Functions Extensions Bindings Cosmos DB library for Python allows you to create a function app with +Cosmos DB Input and define the type as a CosmosClient, DatabaseProxy, or ContainerProxy. Instead of receiving +a DocumentList, when the function is executed, the type returned will be the defined SDK-type and have all of the +properties and methods available as seen in the Azure Storage Cosmos DB library for Python. + + +```python +import logging +import azure.functions as func +import azurefunctions.extensions.bindings.cosmosdb as cosmos + +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) + +@app.route(route="cosmos") +@app.cosmos_db_input(arg_name="container", + connection="CosmosDBConnection", + database_name="db_name", + container_name="container_name") +def get_docs(req: func.HttpRequest, client: cosmos.CosmosClient): + databases = client.list_databases() + for db in databases: + logging.info(f"Found database with ID: {db.get('id')}") + + return "ok" +``` + +## Troubleshooting +### General +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). + +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`. + +## Next steps + +### More sample code + +Get started with our [Cosmos DB samples](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-cosmosdb/samples). + +Several samples are available in this GitHub repository. These samples provide example code for additional scenarios commonly encountered while working with Cosmos DB: + +* [cosmosdb_samples_cosmosclient](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-binding-cosmosdb/samples/cosmosdb_samples_cosmosclient) - Examples for using the CosmosClient type: + * From CosmosDBInput + +* [cosmosdb_samples_databaseproxy](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy) - Examples for using the DatabaseProxy type: + * From CosmosDBInput + +* [cosmosdb_samples_containerclient](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy) - Examples for using the ContainerProxy type: + * From CosmosDBInput + +### Additional documentation +For more information on the Azure Cosmos DB SDK, see the [Azure Cosmos DB documentation](https://learn.microsoft.com/en-us/azure/cosmos-db/) on learn.microsoft.com +and the [Azure Cosmos DB README](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/cosmos/azure-cosmos). + +## Contributing +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. + +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. + +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 [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/__init__.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/__init__.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/__init__.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/__init__.py new file mode 100644 index 0000000..8db66d3 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/__init__.py @@ -0,0 +1 @@ +__path__ = __import__("pkgutil").extend_path(__path__, __name__) diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/__init__.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/__init__.py new file mode 100644 index 0000000..6fbb89a --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/__init__.py @@ -0,0 +1,16 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from .cosmosClient import CosmosClient +from .databaseProxy import DatabaseProxy +from .containerProxy import ContainerProxy +from .cosmosClientConverter import CosmosClientConverter + +__all__ = [ + "CosmosClient", + "DatabaseProxy", + "ContainerProxy", + "CosmosClientConverter" +] + +__version__ = "1.0.0a1" diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/containerProxy.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/containerProxy.py new file mode 100644 index 0000000..8e17a80 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/containerProxy.py @@ -0,0 +1,50 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json + +from azure.cosmos import ContainerProxy as ContainerProxySdk +from azurefunctions.extensions.base import Datum, SdkType +from .utils import get_connection_string, using_managed_identity, get_cosmos_client + + +class ContainerProxy(SdkType): + def __init__(self, *, data: Datum) -> None: + # model_binding_data properties + self._data = data + self._version = None + self._source = None + self._content_type = None + self._database_name = None + self._container_name = None + self._connection = None + self._using_managed_identity = False + self._preferred_locations = None + if self._data: + self._version = data.version + self._source = data.source + self._content_type = data.content_type + content_json = json.loads(data.content) + self._database_name = content_json.get("DatabaseName") + self._container_name = content_json.get("ContainerName") + self._connection = get_connection_string(content_json.get("Connection")) + self._using_managed_identity = using_managed_identity( + content_json.get("Connection") + ) + self._preferred_locations = content_json.get("PreferredLocations") + + def get_sdk_type(self) -> ContainerProxySdk: + """ + There are two ways to create a CosmosClient: + 1. Through the constructor: this is the only option when using Managed Identity + 2. Through from_connection_string: when not using Managed Identity + + We track if Managed Identity is being used through a flag. + """ + if not self._data: + raise ValueError(f"Unable to create {self.__class__.__name__} SDK type.") + + cosmos_client = get_cosmos_client(self._using_managed_identity, + self._connection, self._preferred_locations) + db_client = cosmos_client.get_database_client(self._database_name) + return db_client.get_container_client(self._container_name) diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/cosmosClient.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/cosmosClient.py new file mode 100644 index 0000000..1243349 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/cosmosClient.py @@ -0,0 +1,48 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json + +from azure.cosmos import CosmosClient as CosmosClientSdk +from azurefunctions.extensions.base import Datum, SdkType +from .utils import get_connection_string, using_managed_identity, get_cosmos_client + + +class CosmosClient(SdkType): + def __init__(self, *, data: Datum) -> None: + # model_binding_data properties + self._data = data + self._version = None + self._source = None + self._content_type = None + self._database_name = None + self._container_name = None + self._connection = None + self._using_managed_identity = False + self._preferred_locations = None + if self._data: + self._version = data.version + self._source = data.source + self._content_type = data.content_type + content_json = json.loads(data.content) + self._database_name = content_json.get("DatabaseName") + self._container_name = content_json.get("ContainerName") + self._connection = get_connection_string(content_json.get("Connection")) + self._using_managed_identity = using_managed_identity( + content_json.get("Connection") + ) + self._preferred_locations = content_json.get("PreferredLocations") + + def get_sdk_type(self) -> CosmosClientSdk: + """ + There are two ways to create a CosmosClient: + 1. Through the constructor: this is the only option when using Managed Identity + 2. Through from_connection_string: when not using Managed Identity + + We track if Managed Identity is being used through a flag. + """ + if not self._data: + raise ValueError(f"Unable to create {self.__class__.__name__} SDK type.") + + return get_cosmos_client(self._using_managed_identity, + self._connection, self._preferred_locations) diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/cosmosClientConverter.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/cosmosClientConverter.py new file mode 100644 index 0000000..fa3e8cb --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/cosmosClientConverter.py @@ -0,0 +1,47 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +from typing import Any + +from azurefunctions.extensions.base import Datum, InConverter, OutConverter + +from .cosmosClient import CosmosClient +from .databaseProxy import DatabaseProxy +from .containerProxy import ContainerProxy + + +class CosmosClientConverter( + InConverter, + OutConverter, + binding="cosmosDB" +): + @classmethod + def check_input_type_annotation(cls, pytype: type) -> bool: + return issubclass( + pytype, (CosmosClient, DatabaseProxy, ContainerProxy) + ) + + @classmethod + def decode(cls, data: Datum, *, trigger_metadata, pytype) -> Any: + if data is None or data.type is None: + return None + + data_type = data.type + + if data_type == "model_binding_data": + data = data.value + else: + raise ValueError( + f'unexpected type of data received for the "Cosmos" binding ' + f": {data_type!r}" + ) + + # Determines which sdk type to return based on pytype + if pytype == CosmosClient: + return CosmosClient(data=data).get_sdk_type() + elif pytype == DatabaseProxy: + return DatabaseProxy(data=data).get_sdk_type() + elif pytype == ContainerProxy: + return ContainerProxy(data=data).get_sdk_type() + else: + return None diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/databaseProxy.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/databaseProxy.py new file mode 100644 index 0000000..9b2ef12 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/databaseProxy.py @@ -0,0 +1,49 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json + +from azure.cosmos import DatabaseProxy as DatabaseProxySdk +from azurefunctions.extensions.base import Datum, SdkType +from .utils import get_connection_string, using_managed_identity, get_cosmos_client + + +class DatabaseProxy(SdkType): + def __init__(self, *, data: Datum) -> None: + # model_binding_data properties + self._data = data + self._version = None + self._source = None + self._content_type = None + self._database_name = None + self._container_name = None + self._connection = None + self._using_managed_identity = False + self._preferred_locations = None + if self._data: + self._version = data.version + self._source = data.source + self._content_type = data.content_type + content_json = json.loads(data.content) + self._database_name = content_json.get("DatabaseName") + self._container_name = content_json.get("ContainerName") + self._connection = get_connection_string(content_json.get("Connection")) + self._using_managed_identity = using_managed_identity( + content_json.get("Connection") + ) + self._preferred_locations = content_json.get("PreferredLocations") + + def get_sdk_type(self) -> DatabaseProxySdk: + """ + There are two ways to create a CosmosClient: + 1. Through the constructor: this is the only option when using Managed Identity + 2. Through from_connection_string: when not using Managed Identity + + We track if Managed Identity is being used through a flag. + """ + if not self._data: + raise ValueError(f"Unable to create {self.__class__.__name__} SDK type.") + + cosmos_client = get_cosmos_client(self._using_managed_identity, + self._connection, self._preferred_locations) + return cosmos_client.get_database_client(self._database_name) diff --git a/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/utils.py b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/utils.py new file mode 100644 index 0000000..4cde456 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/azurefunctions/extensions/bindings/cosmosdb/utils.py @@ -0,0 +1,57 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import os +from azure.identity import DefaultAzureCredential +from azure.cosmos import CosmosClient as CosmosClientSdk + + +def get_connection_string(connection_string: str) -> str: + """ + Validates and returns the Cosmos DB connection string or endpoint URI. + Supports both App Settings and managed identity-based configurations. + + Expected formats: + 1. Not using managed identity: the environment variable exists as is. + 2. Using managed identity: __accountEndpoint must be appended. + 3. None of these cases existed, so the connection variable is invalid. + """ + if connection_string is None: + raise ValueError( + "Cosmos DB connection string cannot be None. " + "Please provide a connection string or account endpoint." + ) + elif connection_string in os.environ: + return os.getenv(connection_string) + elif connection_string + "__accountEndpoint" in os.environ: + return os.getenv(connection_string + "__accountEndpoint") + else: + raise ValueError( + f"Cosmos DB connection string {connection_string} does not exist. " + f"Please make sure that it is a defined App Setting." + ) + + +def using_managed_identity(connection_name: str) -> bool: + """ + Determines if managed identity is being used for Cosmos DB access + by checking for a __accountEndpoint suffix. + """ + return os.getenv(connection_name + "__accountEndpoint") is not None + + +def get_cosmos_client(using_managed_identity: bool, + connection: str, preferred_locations: str) -> CosmosClientSdk: + pl = [] + if preferred_locations: + pl = [location.strip() for location in preferred_locations.split(",")] + + cosmos_client = ( + CosmosClientSdk( + url=connection, credential=DefaultAzureCredential(), + preferred_locations=pl + ) + if using_managed_identity + else CosmosClientSdk.from_connection_string(connection, preferred_locations=pl) + ) + + return cosmos_client diff --git a/azurefunctions-extensions-bindings-cosmosdb/pyproject.toml b/azurefunctions-extensions-bindings-cosmosdb/pyproject.toml new file mode 100644 index 0000000..00eed2f --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "azurefunctions-extensions-bindings-cosmosdb" +dynamic = ["version"] +requires-python = ">=3.9" +authors = [{ name = "Azure Functions team at Microsoft Corp.", email = "azurefunctions@microsoft.com"}] +description = "Cosmos DB Python worker extension for Azure Functions." +readme = "README.md" +license = {text = "MIT License"} +classifiers= [ + 'License :: OSI Approved :: MIT License', + 'Intended Audience :: Developers', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Operating System :: Microsoft :: Windows', + 'Operating System :: POSIX', + 'Operating System :: MacOS :: MacOS X', + 'Environment :: Web Environment', + 'Development Status :: 5 - Production/Stable', + ] +dependencies = [ + 'azurefunctions-extensions-base', + 'azure-cosmos~=4.9.0', + 'azure-identity~=1.19.0' + ] + +[project.optional-dependencies] +dev = [ + 'pytest', + 'pytest-cov', + 'coverage', + 'pytest-instafail', + 'pre-commit', + 'mypy', + 'flake8' + ] + +[tool.setuptools.dynamic] +version = {attr = "azurefunctions.extensions.bindings.cosmosdb.__version__"} + +[tool.setuptools.packages.find] +exclude = [ + 'azurefunctions.extensions.bindings','azurefunctions.extensions', + 'azurefunctions', 'tests', 'samples' + ] + diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/README.md b/azurefunctions-extensions-bindings-cosmosdb/samples/README.md new file mode 100644 index 0000000..dec6d4e --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/README.md @@ -0,0 +1,65 @@ +--- +page_type: sample +languages: + - python +products: + - azure + - azure-functions + - azure-functions-extensions + - azurefunctions-extensions-bindings-cosmosdb +urlFragment: extension-cosmosdb-samples +--- + +# Azure Functions Extension Cosmos DB library for Python samples + +These are code samples that show common scenario operations with the Azure Functions Extension Cosmos DB library. + +These samples relate to the Azure Storage Cosmos DB client library being used as part of a Python Function App. For +examples on how to use the Azure Storage Cosmos DB client library, please see [Azure Cosmos samples](https://github.com/Azure/azure-sdk-for-python/tree/main/sdk/cosmos/azure-cosmos/samples) + +* [cosmosdb_samples_cosmosclient](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-binding-cosmosdb/samples/cosmosdb_samples_cosmosclient) - Examples for using the CosmosClient type: + * From CosmosDBInput + +* [cosmosdb_samples_databaseproxy](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy) - Examples for using the DatabaseProxy type: + * From CosmosDBInput + +* [cosmosdb_samples_containerclient](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy) - Examples for using the ContainerProxy type: + * From CosmosDBInput + +## Prerequisites +* 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). +* You must have an [Azure subscription](https://azure.microsoft.com/free/) and an +[Azure storage account](https://docs.microsoft.com/azure/storage/common/storage-account-overview) to use this package. + +## Setup + +1. Install [Core Tools](https://learn.microsoft.com/en-us/azure/azure-functions/functions-run-local?tabs=windows%2Cisolated-process%2Cnode-v4%2Cpython-v2%2Chttp-trigger%2Ccontainer-apps&pivots=programming-language-python) +2. Install the Azure Functions Extension Cosmos DB library for Python with [pip](https://pypi.org/project/pip/): + +```bash +pip install azurefunctions-extensions-bindings-cosmosdb +``` + +3. Clone or download this sample repository +4. Open the sample folder in Visual Studio Code or your IDE of choice. + +## Running the samples + +1. Open a terminal window and `cd` to the directory that the sample you wish to run is saved in. +2. Set the environment variables specified in the sample file you wish to run. +3. Install the required dependencies +```bash +pip install -r requirements.txt +``` +4. Start the Functions runtime +```bash +func start +``` +5. Execute the function by sending an HTTP request to the local endpoint, +based on the type of function you wish to execute. + +## Next steps + +Visit the [SDK-type bindings in Python reference documentation](https://learn.microsoft.com/en-us/azure/azure-functions/functions-reference-python?tabs=get-started%2Casgi%2Capplication-level&pivots=python-mode-decorators#sdk-type-bindings-preview) to learn more about how to use SDK-type bindings in a Python Function App and the +[API reference documentation](https://learn.microsoft.com/en-us/python/api/azure-cosmos/azure.cosmos?view=azure-python) to learn more about +what you can do with the Azure Cosmos DB client library. \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/function_app.py b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/function_app.py new file mode 100644 index 0000000..9150a44 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/function_app.py @@ -0,0 +1,38 @@ +# ------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for +# license information. +# -------------------------------------------------------------------------- + +import logging + +import azure.functions as func +import azurefunctions.extensions.bindings.cosmosdb as cosmos + +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) + +""" +FOLDER: cosmosdb_samples_containerproxy +DESCRIPTION: + These samples demonstrate how to obtain a ContainerProxy from a Cosmos DB Input function app binding. +USAGE: + Set the environment variables with your own values before running the + sample: + 1) CosmosDBConnection - the connection string to your Cosmos DB instance + + Set database_name and container_name to the database name the and container name you want to use + as inputs to the function (required). +""" + + +@app.route(route="container") +@app.cosmos_db_input(arg_name="container", + connection="CosmosDBConnection", + database_name="db_name", + container_name="container_name") +def get_docs(req: func.HttpRequest, container: cosmos.ContainerProxy): + docs = container.query_items(query="SELECT * FROM c", enable_cross_partition_query=True) + for d in docs: + logging.info(f"Found document: {d}") + + return "ok" diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/host.json b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/host.json new file mode 100644 index 0000000..9df9136 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/local.settings.json b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/local.settings.json new file mode 100644 index 0000000..9606630 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "CosmosDBConnection": "" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/requirements.txt b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/requirements.txt new file mode 100644 index 0000000..3d1ba04 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_containerproxy/requirements.txt @@ -0,0 +1,6 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +azurefunctions-extensions-bindings-cosmosdb \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/function_app.py b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/function_app.py new file mode 100644 index 0000000..47912f8 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/function_app.py @@ -0,0 +1,30 @@ +import logging + +import azure.functions as func +import azurefunctions.extensions.bindings.cosmosdb as cosmos + +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) + + +""" +FOLDER: cosmosdb_samples_cosmosclient +DESCRIPTION: + These samples demonstrate how to obtain a CosmosClient from a Cosmos DB Input function app binding. +USAGE: + Set the environment variables with your own values before running the + sample: + 1) CosmosDBConnection - the connection string to your Cosmos DB instance +""" + + +@app.route(route="cosmos") +@app.cosmos_db_input(arg_name="client", + connection="CosmosDBConnection", + database_name=None, + container_name=None) +def get_docs(req: func.HttpRequest, client: cosmos.CosmosClient): + databases = client.list_databases() + for db in databases: + logging.info(f"Found database with ID: {db.get('id')}") + + return "ok" diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/host.json b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/host.json new file mode 100644 index 0000000..9df9136 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/local.settings.json b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/local.settings.json new file mode 100644 index 0000000..9606630 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "CosmosDBConnection": "" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/requirements.txt b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/requirements.txt new file mode 100644 index 0000000..3d1ba04 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_cosmosclient/requirements.txt @@ -0,0 +1,6 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +azurefunctions-extensions-bindings-cosmosdb \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/function_app.py b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/function_app.py new file mode 100644 index 0000000..056e951 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/function_app.py @@ -0,0 +1,32 @@ +import logging + +import azure.functions as func +import azurefunctions.extensions.bindings.cosmosdb as cosmos + +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) + + +""" +FOLDER: cosmosdb_samples_databaseproxy +DESCRIPTION: + These samples demonstrate how to obtain a DatabaseProxy from a Cosmos DB Input function app binding. +USAGE: + Set the environment variables with your own values before running the + sample: + 1) CosmosDBConnection - the connection string to your Cosmos DB instance + + Set database_name to the database you want to use as an input to the function (required). +""" + + +@app.route(route="database") +@app.cosmos_db_input(arg_name="container", + connection="CosmosDBConnection", + database_name="db_name", + container_name=None) +def get_docs(req: func.HttpRequest, database: cosmos.DatabaseProxy): + containers = database.list_containers() + for c in containers: + logging.info(f"Found container with ID: {c.get('id')}") + + return "ok" diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/host.json b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/host.json new file mode 100644 index 0000000..9df9136 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/host.json @@ -0,0 +1,15 @@ +{ + "version": "2.0", + "logging": { + "applicationInsights": { + "samplingSettings": { + "isEnabled": true, + "excludedTypes": "Request" + } + } + }, + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/local.settings.json b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/local.settings.json new file mode 100644 index 0000000..9606630 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/local.settings.json @@ -0,0 +1,7 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "CosmosDBConnection": "" + } +} \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/requirements.txt b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/requirements.txt new file mode 100644 index 0000000..3d1ba04 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/samples/cosmosdb_samples_databaseproxy/requirements.txt @@ -0,0 +1,6 @@ +# DO NOT include azure-functions-worker in this file +# The Python Worker is managed by Azure Functions platform +# Manually managing azure-functions-worker may cause unexpected issues + +azure-functions +azurefunctions-extensions-bindings-cosmosdb \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-cosmosdb/tests/__init__.py b/azurefunctions-extensions-bindings-cosmosdb/tests/__init__.py new file mode 100644 index 0000000..3a41690 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/tests/__init__.py @@ -0,0 +1,17 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import os.path +import sys +import unittest + + +def suite(): + test_loader = unittest.TestLoader() + return test_loader.discover(os.path.dirname(__file__), pattern="test_*.py") + + +if __name__ == "__main__": + runner = unittest.runner.TextTestRunner() + result = runner.run(suite()) + sys.exit(not result.wasSuccessful()) diff --git a/azurefunctions-extensions-bindings-cosmosdb/tests/test_code_quality.py b/azurefunctions-extensions-bindings-cosmosdb/tests/test_code_quality.py new file mode 100644 index 0000000..9d08a97 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/tests/test_code_quality.py @@ -0,0 +1,53 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import pathlib +import subprocess +import sys +import unittest + +ROOT_PATH = pathlib.Path(__file__).parent.parent.parent + + +class TestCodeQuality(unittest.TestCase): + def test_mypy(self): + try: + import mypy # NoQA + except ImportError as e: + raise unittest.SkipTest('mypy module is missing') from e + + try: + subprocess.run( + [sys.executable, '-m', 'mypy', '-m', + 'azurefunctions-extensions-bindings-cosmosdb'], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=str(ROOT_PATH)) + except subprocess.CalledProcessError as ex: + output = ex.output.decode() + raise AssertionError( + f'mypy validation failed:\n{output}') from None + + def test_flake8(self): + try: + import flake8 # NoQA + except ImportError as e: + raise unittest.SkipTest('flake8 module is missing') from e + + config_path = ROOT_PATH / '.flake8' + if not config_path.exists(): + raise unittest.SkipTest('could not locate the .flake8 file') + + try: + subprocess.run( + [sys.executable, '-m', 'flake8', + 'azurefunctions-extensions-bindings-cosmosdb', + '--config', str(config_path)], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=str(ROOT_PATH)) + except subprocess.CalledProcessError as ex: + output = ex.output.decode() + raise AssertionError( + f'flake8 validation failed:\n{output}') from None diff --git a/azurefunctions-extensions-bindings-cosmosdb/tests/test_containerproxy.py b/azurefunctions-extensions-bindings-cosmosdb/tests/test_containerproxy.py new file mode 100644 index 0000000..abe5c38 --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/tests/test_containerproxy.py @@ -0,0 +1,293 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import unittest +from enum import Enum +from typing import Optional + +from azure.cosmos import ContainerProxy as ContainerProxySdk +from azurefunctions.extensions.base import Datum + +from azurefunctions.extensions.bindings.cosmosdb import ( + CosmosClientConverter, ContainerProxy +) + + +# Mock classes for testing +class MockMBD: + def __init__(self, version: str, source: str, content_type: str, content: str): + self.version = version + self.source = source + self.content_type = content_type + self.content = content + + +class MockBindingDirection(Enum): + IN = 0 + OUT = 1 + INOUT = 2 + + +class MockBinding: + def __init__( + self, + name: str, + direction: MockBindingDirection, + data_type=None, + type: Optional[str] = None, + ): # NoQa + self.type = type + self.name = name + self._direction = direction + self._data_type = data_type + self._dict = { + "direction": self._direction, + "dataType": self._data_type, + "type": self.type, + } + + @property + def data_type(self) -> Optional[int]: + return self._data_type.value if self._data_type else None + + @property + def direction(self) -> int: + return self._direction.value + + +class MockParamTypeInfo: + def __init__(self, binding_name: str, pytype: type): + self.binding_name = binding_name + self.pytype = pytype + + +class MockFunction: + def __init__(self, bindings: MockBinding): + self._bindings = bindings + + +class TestContainerProxy(unittest.TestCase): + def test_input_type(self): + check_input_type = CosmosClientConverter.check_input_type_annotation + self.assertTrue(check_input_type(ContainerProxy)) + self.assertFalse(check_input_type(str)) + self.assertFalse(check_input_type(bytes)) + self.assertFalse(check_input_type(bytearray)) + + def test_input_none(self): + result = CosmosClientConverter.decode( + data=None, trigger_metadata=None, pytype=ContainerProxy + ) + self.assertIsNone(result) + + datum: Datum = Datum(value=b"string_content", type=None) + result = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=ContainerProxy + ) + self.assertIsNone(result) + + def test_input_incorrect_type(self): + datum: Datum = Datum(value=b"string_content", type="bytearray") + with self.assertRaises(ValueError): + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=ContainerProxy + ) + + def test_input_empty(self): + datum: Datum = Datum(value={}, type="model_binding_data") + with self.assertRaises(ValueError): + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=ContainerProxy + ) + + def test_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "CosmosDBConnection" + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + result: ContainerProxy = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=ContainerProxy + ) + + self.assertIsNotNone(result) + self.assertIsInstance(result, ContainerProxySdk) + + sdk_result = ContainerProxy(data=datum.value).get_sdk_type() + self.assertIsNotNone(sdk_result) + self.assertIsInstance(sdk_result, ContainerProxySdk) + + def test_invalid_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "NotARealConnectionString" + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + with self.assertRaises(ValueError) as e: + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=ContainerProxy + ) + self.assertEqual( + e.exception.args[0], + "Cosmos DB connection string NotARealConnectionString does not exist. " + "Please make sure that it is a defined App Setting.", + ) + + def test_none_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": None + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + with self.assertRaises(ValueError) as e: + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=ContainerProxy + ) + self.assertEqual( + e.exception.args[0], + "Cosmos DB connection string cannot be None. " + "Please provide a connection string or account endpoint.", + ) + + # TODO: Fix CI for this test + # def test_input_populated_managed_identity_input(self): + # content = { + # "DatabaseName": "test-db", + # "ContainerName": "test-items", + # "Connection": "input" + # } + + # sample_mbd = MockMBD( + # version="1.0", + # source="CosmosDB", + # content_type="application/json", + # content=json.dumps(content), + # ) + + # datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + # result: ContainerProxy = CosmosClientConverter.decode( + # data=datum, trigger_metadata=None, pytype=ContainerProxy + # ) + + # self.assertIsNotNone(result) + # self.assertIsInstance(result, ContainerProxySdk) + + # sdk_result = ContainerProxy(data=datum.value).get_sdk_type() + + # self.assertIsNotNone(sdk_result) + # self.assertIsInstance(sdk_result, ContainerProxySdk) + + def test_input_invalid_pytype(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "CosmosDBConnection" + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + result: ContainerProxy = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype="str" + ) + + self.assertIsNone(result) + + def test_container_proxy_invalid_creation(self): + # Create test binding + mock_cosmos = MockBinding( + name="cosmosDB", direction=MockBindingDirection.IN, data_type=None, + type="cosmosDB" + ) + + # Create test input_types dict + mock_input_types = { + "cosmosDB": MockParamTypeInfo(binding_name="cosmosDB", pytype=bytes) + } + + # Create test indexed_function + mock_indexed_functions = MockFunction(bindings=[mock_cosmos]) + + dict_repr, logs = CosmosClientConverter.get_raw_bindings( + mock_indexed_functions, mock_input_types + ) + + self.assertEqual( + dict_repr, + [ + '{"direction": "MockBindingDirection.IN", ' + '"type": "cosmosDB", ' + '"properties": ' + '{"SupportsDeferredBinding": false}}' + ], + ) + + self.assertEqual(logs, {"cosmosDB": {bytes: "False"}}) + + def test_container_proxy_valid_creation(self): + # Create test binding + mock_cosmos = MockBinding( + name="client", + direction=MockBindingDirection.IN, + data_type=None, + type="cosmosDB", + ) + + # Create test input_types dict + mock_input_types = { + "client": MockParamTypeInfo( + binding_name="cosmosDB", pytype=ContainerProxy + ) + } + + # Create test indexed_function + mock_indexed_functions = MockFunction(bindings=[mock_cosmos]) + + dict_repr, logs = CosmosClientConverter.get_raw_bindings( + mock_indexed_functions, mock_input_types + ) + + self.assertEqual( + dict_repr, + [ + '{"direction": "MockBindingDirection.IN", ' + '"type": "cosmosDB", ' + '"properties": ' + '{"SupportsDeferredBinding": true}}' + ], + ) + + self.assertEqual(logs, {"client": {ContainerProxy: "True"}}) diff --git a/azurefunctions-extensions-bindings-cosmosdb/tests/test_cosmosclient.py b/azurefunctions-extensions-bindings-cosmosdb/tests/test_cosmosclient.py new file mode 100644 index 0000000..313c30c --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/tests/test_cosmosclient.py @@ -0,0 +1,292 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import unittest +from enum import Enum +from typing import Optional + +from azure.cosmos import CosmosClient as CosmosClientSdk +from azurefunctions.extensions.base import Datum + +from azurefunctions.extensions.bindings.cosmosdb import ( + CosmosClient, CosmosClientConverter +) + + +# Mock classes for testing +class MockMBD: + def __init__(self, version: str, source: str, content_type: str, content: str): + self.version = version + self.source = source + self.content_type = content_type + self.content = content + + +class MockBindingDirection(Enum): + IN = 0 + OUT = 1 + INOUT = 2 + + +class MockBinding: + def __init__( + self, + name: str, + direction: MockBindingDirection, + data_type=None, + type: Optional[str] = None, + ): # NoQa + self.type = type + self.name = name + self._direction = direction + self._data_type = data_type + self._dict = { + "direction": self._direction, + "dataType": self._data_type, + "type": self.type, + } + + @property + def data_type(self) -> Optional[int]: + return self._data_type.value if self._data_type else None + + @property + def direction(self) -> int: + return self._direction.value + + +class MockParamTypeInfo: + def __init__(self, binding_name: str, pytype: type): + self.binding_name = binding_name + self.pytype = pytype + + +class MockFunction: + def __init__(self, bindings: MockBinding): + self._bindings = bindings + + +class TestCosmosClient(unittest.TestCase): + def test_input_type(self): + check_input_type = CosmosClientConverter.check_input_type_annotation + self.assertTrue(check_input_type(CosmosClient)) + self.assertFalse(check_input_type(str)) + self.assertFalse(check_input_type(bytes)) + self.assertFalse(check_input_type(bytearray)) + + def test_input_none(self): + result = CosmosClientConverter.decode( + data=None, trigger_metadata=None, pytype=CosmosClient + ) + self.assertIsNone(result) + + datum: Datum = Datum(value=b"string_content", type=None) + result = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=CosmosClient + ) + self.assertIsNone(result) + + def test_input_incorrect_type(self): + datum: Datum = Datum(value=b"string_content", type="bytearray") + with self.assertRaises(ValueError): + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=CosmosClient + ) + + def test_input_empty(self): + datum: Datum = Datum(value={}, type="model_binding_data") + with self.assertRaises(ValueError): + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=CosmosClient + ) + + def test_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "CosmosDBConnection" + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + result: CosmosClient = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=CosmosClient + ) + + self.assertIsNotNone(result) + self.assertIsInstance(result, CosmosClientSdk) + + sdk_result = CosmosClient(data=datum.value).get_sdk_type() + + self.assertIsNotNone(sdk_result) + self.assertIsInstance(sdk_result, CosmosClientSdk) + + def test_invalid_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "NotARealConnectionString", + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + with self.assertRaises(ValueError) as e: + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=CosmosClient + ) + self.assertEqual( + e.exception.args[0], + "Cosmos DB connection string NotARealConnectionString does not exist. " + "Please make sure that it is a defined App Setting.", + ) + + def test_none_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": None, + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + with self.assertRaises(ValueError) as e: + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=CosmosClient + ) + self.assertEqual( + e.exception.args[0], + "Cosmos DB connection string cannot be None. " + "Please provide a connection string or account endpoint.", + ) + + # TODO: Fix CI for this test + # def test_input_populated_managed_identity_input(self): + # content = { + # "DatabaseName": "test-db", + # "ContainerName": "test-items", + # "Connection": "input", + # } + + # sample_mbd = MockMBD( + # version="1.0", + # source="CosmosDB", + # content_type="application/json", + # content=json.dumps(content), + # ) + + # datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + # result: CosmosClient = CosmosClientConverter.decode( + # data=datum, trigger_metadata=None, pytype=CosmosClient + # ) + + # self.assertIsNotNone(result) + # self.assertIsInstance(result, CosmosClientSdk) + + # sdk_result = CosmosClient(data=datum.value).get_sdk_type() + + # self.assertIsNotNone(sdk_result) + # self.assertIsInstance(sdk_result, CosmosClientSdk) + + def test_input_invalid_pytype(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "CosmosDBConnection", + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + result: CosmosClient = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype="str" + ) + + self.assertIsNone(result) + + def test_cosmos_client_invalid_creation(self): + # Create test binding + mock_cosmos = MockBinding( + name="cosmosDB", direction=MockBindingDirection.IN, data_type=None, + type="cosmosDB" + ) + + # Create test input_types dict + mock_input_types = { + "cosmosDB": MockParamTypeInfo(binding_name="cosmosDB", pytype=bytes) + } + + # Create test indexed_function + mock_indexed_functions = MockFunction(bindings=[mock_cosmos]) + + dict_repr, logs = CosmosClientConverter.get_raw_bindings( + mock_indexed_functions, mock_input_types + ) + + self.assertEqual( + dict_repr, + [ + '{"direction": "MockBindingDirection.IN", ' + '"type": "cosmosDB", ' + '"properties": ' + '{"SupportsDeferredBinding": false}}' + ], + ) + + self.assertEqual(logs, {"cosmosDB": {bytes: "False"}}) + + def test_cosmos_client_valid_creation(self): + # Create test binding + mock_cosmos = MockBinding( + name="client", + direction=MockBindingDirection.IN, + data_type=None, + type="cosmosDB", + ) + + # Create test input_types dict + mock_input_types = { + "client": MockParamTypeInfo(binding_name="cosmosDB", pytype=CosmosClient) + } + + # Create test indexed_function + mock_indexed_functions = MockFunction(bindings=[mock_cosmos]) + + dict_repr, logs = CosmosClientConverter.get_raw_bindings( + mock_indexed_functions, mock_input_types + ) + + self.assertEqual( + dict_repr, + [ + '{"direction": "MockBindingDirection.IN", ' + '"type": "cosmosDB", ' + '"properties": ' + '{"SupportsDeferredBinding": true}}' + ], + ) + + self.assertEqual(logs, {"client": {CosmosClient: "True"}}) diff --git a/azurefunctions-extensions-bindings-cosmosdb/tests/test_databaseproxy.py b/azurefunctions-extensions-bindings-cosmosdb/tests/test_databaseproxy.py new file mode 100644 index 0000000..83e734c --- /dev/null +++ b/azurefunctions-extensions-bindings-cosmosdb/tests/test_databaseproxy.py @@ -0,0 +1,294 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. + +import json +import unittest +from enum import Enum +from typing import Optional + +from azure.cosmos import DatabaseProxy as DatabaseProxySdk +from azurefunctions.extensions.base import Datum + +from azurefunctions.extensions.bindings.cosmosdb import ( + CosmosClientConverter, DatabaseProxy +) + + +# Mock classes for testing +class MockMBD: + def __init__(self, version: str, source: str, content_type: str, content: str): + self.version = version + self.source = source + self.content_type = content_type + self.content = content + + +class MockBindingDirection(Enum): + IN = 0 + OUT = 1 + INOUT = 2 + + +class MockBinding: + def __init__( + self, + name: str, + direction: MockBindingDirection, + data_type=None, + type: Optional[str] = None, + ): # NoQa + self.type = type + self.name = name + self._direction = direction + self._data_type = data_type + self._dict = { + "direction": self._direction, + "dataType": self._data_type, + "type": self.type, + } + + @property + def data_type(self) -> Optional[int]: + return self._data_type.value if self._data_type else None + + @property + def direction(self) -> int: + return self._direction.value + + +class MockParamTypeInfo: + def __init__(self, binding_name: str, pytype: type): + self.binding_name = binding_name + self.pytype = pytype + + +class MockFunction: + def __init__(self, bindings: MockBinding): + self._bindings = bindings + + +class TestDatabaseProxy(unittest.TestCase): + def test_input_type(self): + check_input_type = CosmosClientConverter.check_input_type_annotation + self.assertTrue(check_input_type(DatabaseProxy)) + self.assertFalse(check_input_type(str)) + self.assertFalse(check_input_type(bytes)) + self.assertFalse(check_input_type(bytearray)) + + def test_input_none(self): + result = CosmosClientConverter.decode( + data=None, trigger_metadata=None, pytype=DatabaseProxy + ) + self.assertIsNone(result) + + datum: Datum = Datum(value=b"string_content", type=None) + result = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=DatabaseProxy + ) + self.assertIsNone(result) + + def test_input_incorrect_type(self): + datum: Datum = Datum(value=b"string_content", type="bytearray") + with self.assertRaises(ValueError): + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=DatabaseProxy + ) + + def test_input_empty(self): + datum: Datum = Datum(value={}, type="model_binding_data") + with self.assertRaises(ValueError): + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=DatabaseProxy + ) + + def test_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "CosmosDBConnection" + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + result: DatabaseProxy = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=DatabaseProxy + ) + + self.assertIsNotNone(result) + self.assertIsInstance(result, DatabaseProxySdk) + + sdk_result = DatabaseProxy(data=datum.value).get_sdk_type() + + self.assertIsNotNone(sdk_result) + self.assertIsInstance(sdk_result, DatabaseProxySdk) + + def test_invalid_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "NotARealConnectionString" + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + with self.assertRaises(ValueError) as e: + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=DatabaseProxy + ) + self.assertEqual( + e.exception.args[0], + "Cosmos DB connection string NotARealConnectionString does not exist. " + "Please make sure that it is a defined App Setting.", + ) + + def test_none_input_populated(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": None + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + with self.assertRaises(ValueError) as e: + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype=DatabaseProxy + ) + self.assertEqual( + e.exception.args[0], + "Cosmos DB connection string cannot be None. " + "Please provide a connection string or account endpoint.", + ) + + # TODO: Fix CI for this test + # def test_input_populated_managed_identity_input(self): + # content = { + # "DatabaseName": "test-db", + # "ContainerName": "test-items", + # "Connection": "input" + # } + + # sample_mbd = MockMBD( + # version="1.0", + # source="CosmosDB", + # content_type="application/json", + # content=json.dumps(content), + # ) + + # datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + # result: DatabaseProxy = CosmosClientConverter.decode( + # data=datum, trigger_metadata=None, pytype=DatabaseProxy + # ) + + # self.assertIsNotNone(result) + # self.assertIsInstance(result, DatabaseProxySdk) + + # sdk_result = DatabaseProxy(data=datum.value).get_sdk_type() + + # self.assertIsNotNone(sdk_result) + # self.assertIsInstance(sdk_result, DatabaseProxySdk) + + def test_input_invalid_pytype(self): + content = { + "DatabaseName": "test-db", + "ContainerName": "test-items", + "Connection": "CosmosDBConnection" + } + + sample_mbd = MockMBD( + version="1.0", + source="CosmosDB", + content_type="application/json", + content=json.dumps(content), + ) + + datum: Datum = Datum(value=sample_mbd, type="model_binding_data") + result: DatabaseProxy = CosmosClientConverter.decode( + data=datum, trigger_metadata=None, pytype="str" + ) + + self.assertIsNone(result) + + def test_database_proxy_invalid_creation(self): + # Create test binding + mock_cosmos = MockBinding( + name="cosmosDB", direction=MockBindingDirection.IN, data_type=None, + type="cosmosDB" + ) + + # Create test input_types dict + mock_input_types = { + "cosmosDB": MockParamTypeInfo(binding_name="cosmosDB", pytype=bytes) + } + + # Create test indexed_function + mock_indexed_functions = MockFunction(bindings=[mock_cosmos]) + + dict_repr, logs = CosmosClientConverter.get_raw_bindings( + mock_indexed_functions, mock_input_types + ) + + self.assertEqual( + dict_repr, + [ + '{"direction": "MockBindingDirection.IN", ' + '"type": "cosmosDB", ' + '"properties": ' + '{"SupportsDeferredBinding": false}}' + ], + ) + + self.assertEqual(logs, {"cosmosDB": {bytes: "False"}}) + + def test_database_proxy_valid_creation(self): + # Create test binding + mock_cosmos = MockBinding( + name="client", + direction=MockBindingDirection.IN, + data_type=None, + type="cosmosDB", + ) + + # Create test input_types dict + mock_input_types = { + "client": MockParamTypeInfo( + binding_name="cosmosDB", pytype=DatabaseProxy + ) + } + + # Create test indexed_function + mock_indexed_functions = MockFunction(bindings=[mock_cosmos]) + + dict_repr, logs = CosmosClientConverter.get_raw_bindings( + mock_indexed_functions, mock_input_types + ) + + self.assertEqual( + dict_repr, + [ + '{"direction": "MockBindingDirection.IN", ' + '"type": "cosmosDB", ' + '"properties": ' + '{"SupportsDeferredBinding": true}}' + ], + ) + + self.assertEqual(logs, {"client": {DatabaseProxy: "True"}}) diff --git a/azurefunctions-extensions-bindings-eventhub/README.md b/azurefunctions-extensions-bindings-eventhub/README.md index 7df067f..6221d2f 100644 --- a/azurefunctions-extensions-bindings-eventhub/README.md +++ b/azurefunctions-extensions-bindings-eventhub/README.md @@ -4,10 +4,14 @@ Azure EventHub sdk (EventData). EventHub types can be generated from: +* EventHub Triggers + +The supported EventHub SDK types include: + +* EventData + [Source code](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-eventhub) [Package (PyPi)](https://pypi.org/project/azurefunctions-extensions-bindings-eventhub/) -| API reference documentation -| Product documentation | [Samples](https://github.com/Azure/azure-functions-python-extensions/tree/dev/azurefunctions-extensions-bindings-eventhub/samples) @@ -51,7 +55,7 @@ import logging import azure.functions as func import azurefunctions.extensions.bindings.eventhub as eh -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) @app.event_hub_message_trigger( arg_name="event", event_hub_name="EVENTHUB_NAME", connection="AzureWebJobsStorage" diff --git a/azurefunctions-extensions-bindings-eventhub/azurefunctions/extensions/bindings/eventhub/eventData.py b/azurefunctions-extensions-bindings-eventhub/azurefunctions/extensions/bindings/eventhub/eventData.py index ffd0f0c..91bbcf0 100644 --- a/azurefunctions-extensions-bindings-eventhub/azurefunctions/extensions/bindings/eventhub/eventData.py +++ b/azurefunctions-extensions-bindings-eventhub/azurefunctions/extensions/bindings/eventhub/eventData.py @@ -48,5 +48,5 @@ def get_sdk_type(self) -> Optional[EventDataSDK]: # https://github.com/Azure/azure-sdk-for-python/issues/39711 if self.decoded_message: return EventDataSDK._from_message(self.decoded_message) - - return None + else: + raise ValueError(f"Unable to create {self.__class__.__name__} SDK type.") diff --git a/azurefunctions-extensions-bindings-eventhub/samples/eventhub_samples_eventdata/function_app.py b/azurefunctions-extensions-bindings-eventhub/samples/eventhub_samples_eventdata/function_app.py index c9cc972..dc5da6b 100644 --- a/azurefunctions-extensions-bindings-eventhub/samples/eventhub_samples_eventdata/function_app.py +++ b/azurefunctions-extensions-bindings-eventhub/samples/eventhub_samples_eventdata/function_app.py @@ -10,7 +10,7 @@ import azure.functions as func import azurefunctions.extensions.bindings.eventhub as eh -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) """ FOLDER: eventhub_samples_eventdata @@ -30,7 +30,7 @@ @app.event_hub_message_trigger( - arg_name="event", event_hub_name="EVENTHUB_NAME", connection="AzureWebJobsStorage" + arg_name="event", event_hub_name="EVENTHUB_NAME", connection="EventHubConnection" ) def eventhub_trigger(event: eh.EventData): logging.info( @@ -40,7 +40,7 @@ def eventhub_trigger(event: eh.EventData): @app.event_hub_message_trigger( - arg_name="events", event_hub_name="EVENTHUB_NAME", connection="AzureWebJobsStorage", cardinality="many" + arg_name="events", event_hub_name="EVENTHUB_NAME", connection="EventHubConnection", cardinality="many" ) def eventhub_trigger(events: List[eh.EventData]): for event in events: diff --git a/azurefunctions-extensions-bindings-eventhub/samples/eventhub_samples_eventdata/local.settings.json b/azurefunctions-extensions-bindings-eventhub/samples/eventhub_samples_eventdata/local.settings.json index 6dc40bb..e02e8b6 100644 --- a/azurefunctions-extensions-bindings-eventhub/samples/eventhub_samples_eventdata/local.settings.json +++ b/azurefunctions-extensions-bindings-eventhub/samples/eventhub_samples_eventdata/local.settings.json @@ -1,7 +1,8 @@ { "IsEncrypted": false, "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", "FUNCTIONS_WORKER_RUNTIME": "python", - "AzureWebJobsStorage": "UseDevelopmentStorage=true" + "EventHubConnection": "" } } \ No newline at end of file diff --git a/azurefunctions-extensions-bindings-eventhub/tests/test_eventdata.py b/azurefunctions-extensions-bindings-eventhub/tests/test_eventdata.py index aed9ee6..dceaf76 100644 --- a/azurefunctions-extensions-bindings-eventhub/tests/test_eventdata.py +++ b/azurefunctions-extensions-bindings-eventhub/tests/test_eventdata.py @@ -77,18 +77,18 @@ def test_input_incorrect_type(self): def test_input_empty_mbd(self): datum: Datum = Datum(value={}, type="model_binding_data") - result: EventData = EventDataConverter.decode( - data=datum, trigger_metadata=None, pytype=EventData - ) - self.assertIsNone(result) + with self.assertRaises(ValueError): + EventDataConverter.decode( + data=datum, trigger_metadata=None, pytype=EventData + ) def test_input_empty_cmbd(self): datum: Datum = Datum(value=MockCMBD([None]), type="collection_model_binding_data") - result: EventData = EventDataConverter.decode( - data=datum, trigger_metadata=None, pytype=EventData - ) - self.assertEqual(result, [None]) + with self.assertRaises(ValueError): + EventDataConverter.decode( + data=datum, trigger_metadata=None, pytype=EventData + ) def test_input_populated_mbd(self): sample_mbd = MockMBD( diff --git a/azurefunctions-extensions-http-fastapi/README.md b/azurefunctions-extensions-http-fastapi/README.md index b0200ab..643e737 100644 --- a/azurefunctions-extensions-http-fastapi/README.md +++ b/azurefunctions-extensions-http-fastapi/README.md @@ -35,7 +35,7 @@ The Azure Functions Extensions Http FastApi library for Python allows you to cre import azure.functions as func from azurefunctions.extensions.http.fastapi import Request, JSONResponse -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) @app.route(route="streaming_upload", methods=[func.HttpMethod.POST]) async def streaming_upload(req: Request) -> JSONResponse: diff --git a/azurefunctions-extensions-http-fastapi/samples/fastapi_samples_streaming_download/function_app.py b/azurefunctions-extensions-http-fastapi/samples/fastapi_samples_streaming_download/function_app.py index 3ee5a55..b6d3b3c 100644 --- a/azurefunctions-extensions-http-fastapi/samples/fastapi_samples_streaming_download/function_app.py +++ b/azurefunctions-extensions-http-fastapi/samples/fastapi_samples_streaming_download/function_app.py @@ -7,7 +7,7 @@ import azure.functions as func from azurefunctions.extensions.http.fastapi import Request, StreamingResponse -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) def generate_sensor_data(): diff --git a/azurefunctions-extensions-http-fastapi/samples/fastapi_samples_streaming_upload/function_app.py b/azurefunctions-extensions-http-fastapi/samples/fastapi_samples_streaming_upload/function_app.py index de3410f..1cfd8d4 100644 --- a/azurefunctions-extensions-http-fastapi/samples/fastapi_samples_streaming_upload/function_app.py +++ b/azurefunctions-extensions-http-fastapi/samples/fastapi_samples_streaming_upload/function_app.py @@ -5,7 +5,7 @@ import azure.functions as func from azurefunctions.extensions.http.fastapi import JSONResponse, Request -app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +app = func.FunctionApp(http_auth_level=func.AuthLevel.FUNCTION) @app.route(route="streaming_upload", methods=[func.HttpMethod.POST]) diff --git a/eng/ci/ci-cosmos-tests.yml b/eng/ci/ci-cosmos-tests.yml new file mode 100644 index 0000000..ef98ed2 --- /dev/null +++ b/eng/ci/ci-cosmos-tests.yml @@ -0,0 +1,35 @@ +trigger: none # ensure this is not ran as a CI build + +pr: + branches: + include: + - dev + - release/* + +resources: + repositories: + - repository: 1es + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + - repository: eng + type: git + name: engineering + ref: refs/tags/release + +variables: + - template: /ci/variables/build.yml@eng + - template: /ci/variables/cfs.yml@eng + +extends: + template: v1/1ES.Unofficial.PipelineTemplate.yml@1es + parameters: + pool: + name: 1es-pool-azfunc + image: 1es-windows-2022 + os: windows + + stages: + - stage: RunCosmosDBUnitTests + jobs: + - template: /eng/templates/official/jobs/cosmosdb-unit-tests.yml@self diff --git a/eng/ci/official-build.yml b/eng/ci/official-build.yml index a341b53..802cba3 100644 --- a/eng/ci/official-build.yml +++ b/eng/ci/official-build.yml @@ -51,6 +51,10 @@ extends: dependsOn: Build jobs: - template: /eng/templates/official/jobs/blob-unit-tests.yml@self + - stage: RunCosmosDBTests + dependsOn: Build + jobs: + - template: /eng/templates/official/jobs/cosmosdb-unit-tests.yml@self - stage: RunEventHubTests dependsOn: Build jobs: diff --git a/eng/ci/public-build.yml b/eng/ci/public-build.yml index 5368b2f..9d69944 100644 --- a/eng/ci/public-build.yml +++ b/eng/ci/public-build.yml @@ -51,6 +51,10 @@ extends: dependsOn: Build jobs: - template: /eng/templates/official/jobs/blob-unit-tests.yml@self + - stage: RunCosmosDBTests + dependsOn: Build + jobs: + - template: /eng/templates/official/jobs/cosmosdb-unit-tests.yml@self - stage: RunEventHubTests dependsOn: Build jobs: diff --git a/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml index ad70054..45e132b 100644 --- a/eng/templates/jobs/build.yml +++ b/eng/templates/jobs/build.yml @@ -10,6 +10,9 @@ jobs: blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' EXTENSION_NAME: 'Blob' + cosmosdb_extension: + EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-cosmosdb' + EXTENSION_NAME: 'CosmosDB' eventhub_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-eventhub' EXTENSION_NAME: 'EventHub' diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml index 307cac8..f28ac31 100644 --- a/eng/templates/official/jobs/build-artifacts.yml +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -10,6 +10,9 @@ jobs: blob_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-blob' EXTENSION_NAME: 'Blob' + cosmosdb_extension: + EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-cosmosdb' + EXTENSION_NAME: 'CosmosDB' eventhub_extension: EXTENSION_DIRECTORY: 'azurefunctions-extensions-bindings-eventhub' EXTENSION_NAME: 'EventHub' diff --git a/eng/templates/official/jobs/cosmosdb-unit-tests.yml b/eng/templates/official/jobs/cosmosdb-unit-tests.yml new file mode 100644 index 0000000..6453822 --- /dev/null +++ b/eng/templates/official/jobs/cosmosdb-unit-tests.yml @@ -0,0 +1,30 @@ +jobs: + - job: "TestPython" + displayName: "Run Cosmos DB Tests" + + strategy: + matrix: + python39: + PYTHON_VERSION: '3.9' + python310: + PYTHON_VERSION: '3.10' + python311: + PYTHON_VERSION: '3.11' + python312: + PYTHON_VERSION: '3.12' + + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + - bash: | + python -m pip install --upgrade pip + cd azurefunctions-extensions-bindings-cosmosdb + python -m pip install -U -e .[dev] + displayName: 'Install dependencies' + - bash: | + python -m pytest -q --instafail azurefunctions-extensions-bindings-cosmosdb/tests/ + env: + CosmosDBConnection: $(CosmosDBConnection) + input__accountEndpoint: $(input__accountEndpoint) + displayName: "Running Cosmos DB $(PYTHON_VERSION) Python Extension Tests"