From 9574a5ce01bb35b9ac88cec77815de75871d76aa Mon Sep 17 00:00:00 2001 From: Varad Meru Date: Wed, 10 Apr 2024 16:34:37 -0500 Subject: [PATCH 01/34] Create codeql.yml (#211) --- .github/workflows/codeql.yml | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..8d21016 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,89 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL" + +on: + push: + branches: [ "dev"] + pull_request: + branches: [ "dev"] + schedule: + - cron: '28 2 * * 0' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} + permissions: + # required for all workflows + security-events: write + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹī¸ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - if: matrix.build-mode == 'manual' + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" From 5a9360bab7acd9c9299feba7d18ddd33ac161c65 Mon Sep 17 00:00:00 2001 From: wangbill <12449837+YunchuWang@users.noreply.github.com> Date: Fri, 12 Apr 2024 08:30:56 -0700 Subject: [PATCH 02/34] BugFix: Asgi Path Param Encoding change to utf-8 (#208) * fix: third party web app, changing request path encoding to utf-8 * fix * style fix * ignore * fix * add tests * fix * Update tests/test_http_wsgi.py Co-authored-by: Fredrik Erlandsson * Update tests/test_http_wsgi.py Co-authored-by: Fredrik Erlandsson * Update test_http_wsgi.py --------- Co-authored-by: Fredrik Erlandsson --- azure/functions/_http_wsgi.py | 7 +++++-- tests/test_http_wsgi.py | 13 +++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/azure/functions/_http_wsgi.py b/azure/functions/_http_wsgi.py index 6df5d16..48d6473 100644 --- a/azure/functions/_http_wsgi.py +++ b/azure/functions/_http_wsgi.py @@ -29,8 +29,11 @@ class WsgiRequest: # Implement interfaces for PEP 3333 environ self.request_method = getattr(func_req, 'method', None) self.script_name = '' - self.path_info = unquote_to_bytes( - getattr(url, 'path', None)).decode('latin-1') # type: ignore + self.path_info = ( + unquote_to_bytes(getattr(url, 'path', None)) # type: ignore + .decode('latin-1' if type(self) is WsgiRequest else 'utf-8') + ) + self.query_string = getattr(url, 'query', None) self.content_type = self._lowercased_headers.get('content-type') self.content_length = str(len(func_req_body)) diff --git a/tests/test_http_wsgi.py b/tests/test_http_wsgi.py index 6397a9d..224d35d 100644 --- a/tests/test_http_wsgi.py +++ b/tests/test_http_wsgi.py @@ -14,6 +14,7 @@ from azure.functions._http_wsgi import ( WsgiResponse, WsgiMiddleware ) +from azure.functions._http_asgi import AsgiRequest class WsgiException(Exception): @@ -211,6 +212,18 @@ class TestHttpWsgi(unittest.TestCase): self.assertEqual(func_response.status_code, 200) self.assertEqual(func_response.get_body(), b'sample string') + def test_path_encoding_utf8(self): + url = 'http://example.com/Pippi%20L%C3%A5ngstrump' + request = AsgiRequest(self._generate_func_request(url=url)) + + self.assertEqual(request.path_info, u'/Pippi L\u00e5ngstrump') + + def test_path_encoding_latin1(self): + url = 'http://example.com/Pippi%20L%C3%A5ngstrump' + request = WsgiRequest(self._generate_func_request(url=url)) + + self.assertEqual(request.path_info, u'/Pippi L\u00c3\u00a5ngstrump') + def _generate_func_request( self, method="POST", From b85eb7a7909198de93fd974dd8225aa9d9cbdbc5 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Fri, 12 Apr 2024 15:54:22 -0500 Subject: [PATCH 03/34] Update __init__.py (#212) --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 7ac4345..d91d70e 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -97,4 +97,4 @@ __all__ = ( 'HttpMethod' ) -__version__ = '1.19.0b3' +__version__ = '1.19.0' From f79ff413db79e31112b0c651a1dcaa0e94ac7b51 Mon Sep 17 00:00:00 2001 From: David Justo Date: Tue, 30 Apr 2024 14:19:16 -0700 Subject: [PATCH 04/34] Add Durable Decorators directly to Python library (#207) * Add naive proof of concept of DF decorators * apply linter feedback * apply further linter feedback --------- Co-authored-by: wangbill <12449837+YunchuWang@users.noreply.github.com> Co-authored-by: gavin-aguiar <80794152+gavin-aguiar@users.noreply.github.com> --- azure/functions/decorators/constants.py | 4 + azure/functions/decorators/function_app.py | 120 +++++++++++++++++++++ setup.py | 3 +- tests/decorators/test_decorators.py | 81 +++++++++++++- 4 files changed, 206 insertions(+), 2 deletions(-) diff --git a/azure/functions/decorators/constants.py b/azure/functions/decorators/constants.py index 12d8e5e..ce439e6 100644 --- a/azure/functions/decorators/constants.py +++ b/azure/functions/decorators/constants.py @@ -30,3 +30,7 @@ DAPR_PUBLISH = "daprPublish" DAPR_INVOKE = "daprInvoke" DAPR_PUBLISH = "daprPublish" DAPR_BINDING = "daprBinding" +ORCHESTRATION_TRIGGER = "orchestrationTrigger" +ACTIVITY_TRIGGER = "activityTrigger" +ENTITY_TRIGGER = "entityTrigger" +DURABLE_CLIENT = "durableClient" diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 6166ae6..172e2b0 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -277,6 +277,42 @@ class DecoratorApi(ABC): self._function_builders: List[FunctionBuilder] = [] self._app_script_file: str = SCRIPT_FILE_NAME + def _invoke_df_decorator(self, df_decorator): + """ + Invoke a Durable Functions decorator from the DF SDK, and store the + resulting :class:`FunctionBuilder` object within the `DecoratorApi`. + + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + function_builder = df_decorator(fb._function._func) + + # remove old function builder from `self` and replace + # it with the result of the DF decorator + self._function_builders.pop() + self._function_builders.append(function_builder) + return function_builder + return decorator() + return wrap + + def _get_durable_blueprint(self): + """Attempt to import the Durable Functions SDK from which DF decorators are + implemented. + """ + + try: + import azure.durable_functions as df + df_bp = df.Blueprint() + return df_bp + except ImportError: + error_message = "Attempted to use a Durable Functions decorator, "\ + "but the `azure-functions-durable` SDK package could not be "\ + "found. Please install `azure-functions-durable` to use "\ + "Durable Functions." + raise Exception(error_message) + @property def app_script_file(self) -> str: """Name of function app script file in which all the functions @@ -443,6 +479,59 @@ class TriggerApi(DecoratorApi, ABC): return wrap + def orchestration_trigger(self, context_name: str, + orchestration: Optional[str] = None): + """Register an Orchestrator Function. + + Parameters + ---------- + context_name: str + Parameter name of the DurableOrchestrationContext object. + orchestration: Optional[str] + Name of Orchestrator Function. + By default, the name of the method is used. + """ + df_bp = self._get_durable_blueprint() + df_decorator = df_bp.orchestration_trigger(context_name, + orchestration) + result = self._invoke_df_decorator(df_decorator) + return result + + def entity_trigger(self, context_name: str, + entity_name: Optional[str] = None): + """Register an Entity Function. + + Parameters + ---------- + context_name: str + Parameter name of the Entity input. + entity_name: Optional[str] + Name of Entity Function. + """ + + df_bp = self._get_durable_blueprint() + df_decorator = df_bp.entity_trigger(context_name, + entity_name) + result = self._invoke_df_decorator(df_decorator) + return result + + def activity_trigger(self, input_name: str, + activity: Optional[str] = None): + """Register an Activity Function. + + Parameters + ---------- + input_name: str + Parameter name of the Activity input. + activity: Optional[str] + Name of Activity Function. + """ + + df_bp = self._get_durable_blueprint() + df_decorator = df_bp.activity_trigger(input_name, activity) + result = self._invoke_df_decorator(df_decorator) + return result + def timer_trigger(self, arg_name: str, schedule: str, @@ -1350,6 +1439,37 @@ class TriggerApi(DecoratorApi, ABC): class BindingApi(DecoratorApi, ABC): """Interface to extend for using existing binding decorator functions.""" + def durable_client_input(self, + client_name: str, + task_hub: Optional[str] = None, + connection_name: Optional[str] = None + ): + """Register a Durable-client Function. + + Parameters + ---------- + client_name: str + Parameter name of durable client. + task_hub: Optional[str] + Used in scenarios where multiple function apps share the + same storage account but need to be isolated from each other. + If not specified, the default value from host.json is used. + This value must match the value used by the target + orchestrator functions. + connection_name: Optional[str] + The name of an app setting that contains a storage account + connection string. The storage account represented by this + connection string must be the same one used by the target + orchestrator functions. If not specified, the default storage + account connection string for the function app is used. + """ + df_bp = self._get_durable_blueprint() + df_decorator = df_bp.durable_client_input(client_name, + task_hub, + connection_name) + result = self._invoke_df_decorator(df_decorator) + return result + def service_bus_queue_output(self, arg_name: str, connection: str, diff --git a/setup.py b/setup.py index 69c8e2d..704dc48 100644 --- a/setup.py +++ b/setup.py @@ -12,7 +12,8 @@ EXTRA_REQUIRES = { 'pytest', 'pytest-cov', 'requests==2.*', - 'coverage' + 'coverage', + 'azure-functions-durable' ] } diff --git a/tests/decorators/test_decorators.py b/tests/decorators/test_decorators.py index 97c50a7..f31a42b 100644 --- a/tests/decorators/test_decorators.py +++ b/tests/decorators/test_decorators.py @@ -6,7 +6,8 @@ from azure.functions.decorators.constants import TIMER_TRIGGER, HTTP_TRIGGER, \ HTTP_OUTPUT, QUEUE, QUEUE_TRIGGER, SERVICE_BUS, SERVICE_BUS_TRIGGER, \ EVENT_HUB, EVENT_HUB_TRIGGER, COSMOS_DB, COSMOS_DB_TRIGGER, BLOB, \ BLOB_TRIGGER, EVENT_GRID_TRIGGER, EVENT_GRID, TABLE, WARMUP_TRIGGER, \ - SQL, SQL_TRIGGER + SQL, SQL_TRIGGER, ORCHESTRATION_TRIGGER, ACTIVITY_TRIGGER, \ + ENTITY_TRIGGER, DURABLE_CLIENT from azure.functions.decorators.core import DataType, AuthLevel, \ BindingDirection, AccessRights, Cardinality from azure.functions.decorators.function_app import FunctionApp @@ -160,6 +161,84 @@ class TestFunctionsApp(unittest.TestCase): ] }) + def test_orchestration_trigger(self): + app = self.func_app + + @app.orchestration_trigger("context") + def dummy1(context): + pass + + func = self._get_user_function(app) + assert_json(self, func, { + "scriptFile": "function_app.py", + "bindings": [ + { + "name": "context", + "type": ORCHESTRATION_TRIGGER, + "direction": BindingDirection.IN + } + ] + }) + + def test_activity_trigger(self): + app = self.func_app + + @app.activity_trigger("arg") + def dummy2(arg): + pass + + func = self._get_user_function(app) + assert_json(self, func, { + "scriptFile": "function_app.py", + "bindings": [ + { + "name": "arg", + "type": ACTIVITY_TRIGGER, + "direction": BindingDirection.IN + } + ] + }) + + def test_entity_trigger(self): + app = self.func_app + + @app.entity_trigger("context") + def dummy3(context): + pass + + func = self._get_user_function(app) + assert_json(self, func, { + "scriptFile": "function_app.py", + "bindings": [ + { + "name": "context", + "type": ENTITY_TRIGGER, + "direction": BindingDirection.IN, + } + ] + }) + + def test_durable_client(self): + app = self.func_app + + @app.generic_trigger(arg_name="req", type=HTTP_TRIGGER) + @app.durable_client_input(client_name="client") + def dummy(client): + pass + + func = self._get_user_function(app) + + self.assertEqual(len(func.get_bindings()), 2) + self.assertTrue(func.is_http_function()) + + output = func.get_bindings()[0] + + self.assertEqual(output.get_dict_repr(), { + "direction": BindingDirection.IN, + "type": DURABLE_CLIENT, + "name": "client" + }) + def test_route_default_args(self): app = self.func_app From c7c31ddf934a4421ea8816a0eab954d96a13a971 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Tue, 30 Apr 2024 16:22:40 -0500 Subject: [PATCH 05/34] Allow http scheme for AsgiRequest (#198) * parse url scheme to allow http or https * added & fixed test * codeowner --------- Co-authored-by: wangbill <12449837+YunchuWang@users.noreply.github.com> --- CODEOWNERS | 4 +++- azure/functions/_http_asgi.py | 5 ++++- tests/test_http_asgi.py | 14 +++++++++++++- 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f9288ca..0877a44 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -9,4 +9,6 @@ # AZURE FUNCTIONS TEAM # For all file changes, github would automatically include the following people in the PRs. # -* @vrdmr @gavin-aguiar @YunchuWang @pdthummar @hallvictoria \ No newline at end of file + +* @vrdmr @gavin-aguiar @YunchuWang @pdthummar @hallvictoria + diff --git a/azure/functions/_http_asgi.py b/azure/functions/_http_asgi.py index 7be3101..372dc0f 100644 --- a/azure/functions/_http_asgi.py +++ b/azure/functions/_http_asgi.py @@ -5,6 +5,7 @@ from typing import Dict, List, Tuple, Optional, Any, Union import logging import asyncio from asyncio import Event, Queue +from urllib.parse import ParseResult, urlparse from warnings import warn from wsgiref.headers import Headers @@ -22,6 +23,8 @@ class AsgiRequest(WsgiRequest): self.asgi_version = ASGI_VERSION self.asgi_spec_version = ASGI_SPEC_VERSION self._headers = func_req.headers + url: ParseResult = urlparse(func_req.url) + self.asgi_url_scheme = url.scheme super().__init__(func_req, func_ctx) def _get_encoded_http_headers(self) -> List[Tuple[bytes, bytes]]: @@ -49,7 +52,7 @@ class AsgiRequest(WsgiRequest): "asgi.spec_version": self.asgi_spec_version, "http_version": "1.1", "method": self.request_method, - "scheme": "https", + "scheme": self.asgi_url_scheme, "path": self.path_info, "raw_path": _raw_path, "query_string": _query_string, diff --git a/tests/test_http_asgi.py b/tests/test_http_asgi.py index 7125cd8..465f760 100644 --- a/tests/test_http_asgi.py +++ b/tests/test_http_asgi.py @@ -198,7 +198,19 @@ class TestHttpAsgiMiddleware(unittest.TestCase): test_body = b'Hello world!' app.response_body = test_body app.response_code = 200 - req = func.HttpRequest(method='get', url='/test', body=b'') + req = self._generate_func_request() + response = AsgiMiddleware(app).handle(req) + + # Verify asserted + self.assertEqual(response.status_code, 200) + self.assertEqual(response.get_body(), test_body) + + def test_middleware_calls_app_http(self): + app = MockAsgiApplication() + test_body = b'Hello world!' + app.response_body = test_body + app.response_code = 200 + req = self._generate_func_request(url="http://a.b.com") response = AsgiMiddleware(app).handle(req) # Verify asserted From 715580fb42e621e1a819ac01eedb95b9cb5c94e3 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Tue, 30 Apr 2024 16:31:57 -0500 Subject: [PATCH 06/34] Update Python Library Version to 1.20.0b1 (#214) Co-authored-by: AzureFunctionsPython --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index d91d70e..7dd45df 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -97,4 +97,4 @@ __all__ = ( 'HttpMethod' ) -__version__ = '1.19.0' +__version__ = '1.20.0b1' From 5433b0921b561b1c9647ca8607023ca685af03fa Mon Sep 17 00:00:00 2001 From: gavin-aguiar <80794152+gavin-aguiar@users.noreply.github.com> Date: Mon, 13 May 2024 09:54:06 -0500 Subject: [PATCH 07/34] Openai decorators (#217) * Added openai decorators * Added openai decorators * Minor type fixes * connection_name typos * Added tests * Added pydocs and minor fixes * Addressed comments * Flake8 fixes * Removed todo comment --------- Co-authored-by: Victoria Hall --- CODEOWNERS | 1 - azure/functions/decorators/constants.py | 9 +- azure/functions/decorators/function_app.py | 455 ++++++++++++++++++++- azure/functions/decorators/openai.py | 216 ++++++++++ tests/decorators/test_openai.py | 181 ++++++++ 5 files changed, 849 insertions(+), 13 deletions(-) create mode 100644 azure/functions/decorators/openai.py create mode 100644 tests/decorators/test_openai.py diff --git a/CODEOWNERS b/CODEOWNERS index 0877a44..978e117 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -11,4 +11,3 @@ # * @vrdmr @gavin-aguiar @YunchuWang @pdthummar @hallvictoria - diff --git a/azure/functions/decorators/constants.py b/azure/functions/decorators/constants.py index ce439e6..0948bc5 100644 --- a/azure/functions/decorators/constants.py +++ b/azure/functions/decorators/constants.py @@ -28,9 +28,16 @@ DAPR_STATE = "daprState" DAPR_SECRET = "daprSecret" DAPR_PUBLISH = "daprPublish" DAPR_INVOKE = "daprInvoke" -DAPR_PUBLISH = "daprPublish" DAPR_BINDING = "daprBinding" ORCHESTRATION_TRIGGER = "orchestrationTrigger" ACTIVITY_TRIGGER = "activityTrigger" ENTITY_TRIGGER = "entityTrigger" DURABLE_CLIENT = "durableClient" +ASSISTANT_SKILLS_TRIGGER = "assistantSkillsTrigger" +TEXT_COMPLETION = "textCompletion" +ASSISTANT_QUERY = "assistantQuery" +EMBEDDINGS = "embeddings" +EMBEDDINGS_STORE = "embeddingsStore" +ASSISTANT_CREATE = "assistantCreate" +ASSISTANT_POST = "assistantPost" +SEMANTIC_SEARCH = "semanticSearch" diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 172e2b0..fe17ceb 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -35,6 +35,11 @@ from azure.functions.decorators.utils import parse_singular_param_to_enum, \ parse_iterable_param_to_enums, StringifyEnumJsonEncoder from azure.functions.http import HttpRequest from .generic import GenericInputBinding, GenericTrigger, GenericOutputBinding +from .openai import AssistantSkillTrigger, OpenAIModels, TextCompletionInput, \ + AssistantCreateOutput, \ + AssistantQueryInput, AssistantPostInput, InputType, EmbeddingsInput, \ + semantic_search_system_prompt, \ + SemanticSearchInput, EmbeddingsStoreOutput from .retry_policy import RetryPolicy from .function_name import FunctionName from .warmup import WarmUpTrigger @@ -294,7 +299,9 @@ class DecoratorApi(ABC): self._function_builders.pop() self._function_builders.append(function_builder) return function_builder + return decorator() + return wrap def _get_durable_blueprint(self): @@ -307,9 +314,10 @@ class DecoratorApi(ABC): df_bp = df.Blueprint() return df_bp except ImportError: - error_message = "Attempted to use a Durable Functions decorator, "\ - "but the `azure-functions-durable` SDK package could not be "\ - "found. Please install `azure-functions-durable` to use "\ + error_message = \ + "Attempted to use a Durable Functions decorator, " \ + "but the `azure-functions-durable` SDK package could not be " \ + "found. Please install `azure-functions-durable` to use " \ "Durable Functions." raise Exception(error_message) @@ -1435,6 +1443,68 @@ class TriggerApi(DecoratorApi, ABC): return wrap + def assistant_skill_trigger(self, + arg_name: str, + function_description: str, + function_name: Optional[str] = None, + parameter_description_json: Optional[str] = None, # NoQA + model: Optional[OpenAIModels] = OpenAIModels.DefaultChatModel, # NoQA + data_type: Optional[ + Union[DataType, str]] = None, + **kwargs: Any) -> Callable[..., Any]: + """ + Assistants build on top of the chat functionality to provide assistants + with custom skills defined as functions. This internally uses the + function calling feature OpenAIs GPT models to select which functions + to invoke and when. + Ref: https://platform.openai.com/docs/guides/function-calling + + You can define functions that can be triggered by assistants by using + + the `assistantSkillTrigger` trigger binding. These functions are + invoked by the extension when an assistant signals that it would like + to invoke a function in response to a user prompt. + + The name of the function, the description provided by the trigger, + and the parameter name are all hints that the underlying language model + use to determine when and how to invoke an assistant function. + + :param arg_name: The name of trigger parameter in the function code. + :param function_description: The description of the assistant function, + which is provided to the model. + :param function_name: The assistant function, which is provided to the + LLM. + :param parameter_description_json: A JSON description of the function + parameter, which is provided to the LLM. + If no description is provided, the description will be autogenerated. + :param model: The OpenAI chat model to use. + :param data_type: Defines how Functions runtime should treat the + parameter value. + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json. + + :return: Decorator function. + + """ + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_trigger( + trigger=AssistantSkillTrigger( + name=arg_name, + function_description=function_description, + function_name=function_name, + parameter_description_json=parameter_description_json, + model=model, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + class BindingApi(DecoratorApi, ABC): """Interface to extend for using existing binding decorator functions.""" @@ -2542,8 +2612,6 @@ class BindingApi(DecoratorApi, ABC): :param arg_name: The name of the variable that represents DaprState output object in function code. - :param arg_name: The name of the variable that represents DaprState - input object in function code. :param state_store: State store containing the state for keys. :param key: The name of the key. :param dapr_address: Dapr address, it is optional field, by default @@ -2597,8 +2665,6 @@ class BindingApi(DecoratorApi, ABC): :param arg_name: The name of the variable that represents DaprState output object in function code. - :param arg_name: The name of the variable that represents DaprState - input object in function code. :param app_id: The dapr app name to invoke. :param method_name: The method name of the app to invoke. :param http_verb: The http verb of the app to invoke. @@ -2653,8 +2719,6 @@ class BindingApi(DecoratorApi, ABC): :param arg_name: The name of the variable that represents DaprState output object in function code. - :param arg_name: The name of the variable that represents DaprState - input object in function code. :param pub_sub_name: The pub/sub name to publish to. :param topic: The name of the topic to publish to. :param dapr_address: Dapr address, it is optional field, by default @@ -2708,8 +2772,6 @@ class BindingApi(DecoratorApi, ABC): :param arg_name: The name of the variable that represents DaprState output object in function code. - :param arg_name: The name of the variable that represents DaprState - input object in function code. :param binding_name: The configured name of the binding. :param operation: The configured operation. :param dapr_address: Dapr address, it is optional field, by default @@ -2740,6 +2802,377 @@ class BindingApi(DecoratorApi, ABC): return wrap + def text_completion_input(self, + arg_name: str, + prompt: str, + model: Optional[OpenAIModels] = OpenAIModels.DefaultChatModel, # NoQA + temperature: Optional[str] = "0.5", + top_p: Optional[str] = None, + max_tokens: Optional[str] = "100", + data_type: Optional[Union[DataType, str]] = None, + **kwargs) \ + -> Callable[..., Any]: + """ + The textCompletion input binding can be used to invoke the + OpenAI Chat Completions API and return the results to the function. + + Ref: https://platform.openai.com/docs/guides/text-generation/chat-completions-vs-completions # NoQA + + The examples below define "who is" HTTP-triggered functions with a + hardcoded `"who is {name}?"` prompt, where `{name}` is the substituted + with the value in the HTTP request path. The OpenAI input binding + invokes the OpenAI GPT endpoint to surface the answer to the prompt to + the function, which then returns the result text as the response + content. + + :param arg_name: The name of binding parameter in the function code. + :param prompt: The prompt to generate completions for, encoded as a + string. + :param model: the ID of the model to use. + :param temperature: The sampling temperature to use, between 0 and 2. + Higher values like 0.8 will make the output more random, while lower + values like 0.2 will make it more focused and deterministic. + :param top_p: An alternative to sampling with temperature, called + nucleus sampling, where the model considers the results of the tokens + with top_p probability mass. So 0.1 means only the tokens comprising + the top 10% probability mass are considered. It's generally recommend + to use this or temperature + :param max_tokens: The maximum number of tokens to generate in the + completion. The token count of your prompt plus max_tokens cannot + exceed the model's context length. Most models have a context length of + 2048 tokens (except for the newest models, which support 4096). + :param data_type: Defines how Functions runtime should treat the + parameter value + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_binding( + binding=TextCompletionInput( + name=arg_name, + prompt=prompt, + model=model, + temperature=temperature, + top_p=top_p, + max_tokens=max_tokens, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + + def assistant_create_output(self, arg_name: str, + data_type: Optional[ + Union[DataType, str]] = None, + **kwargs) \ + -> Callable[..., Any]: + """ + The assistantCreate output binding creates a new assistant with a + specified system prompt. + + :param arg_name: The name of binding parameter in the function code. + :param data_type: Defines how Functions runtime should treat the + parameter value + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_binding( + binding=AssistantCreateOutput( + name=arg_name, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + + def assistant_query_input(self, + arg_name: str, + id: str, + timestamp_utc: str, + data_type: Optional[ + Union[DataType, str]] = None, + **kwargs) \ + -> Callable[..., Any]: + """ + The assistantQuery input binding fetches the assistant history and + passes it to the function. + + :param arg_name: The name of binding parameter in the function code. + :param timestamp_utc: the timestamp of the earliest message in the chat + history to fetch. The timestamp should be in ISO 8601 format - for + example, 2023-08-01T00:00:00Z. + :param id: The ID of the Assistant to query. + :param data_type: Defines how Functions runtime should treat the + parameter value + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_binding( + binding=AssistantQueryInput( + name=arg_name, + id=id, + timestamp_utc=timestamp_utc, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + + def assistant_post_input(self, arg_name: str, + id: str, + user_message: str, + model: Optional[str] = None, + data_type: Optional[ + Union[DataType, str]] = None, + **kwargs) \ + -> Callable[..., Any]: + """ + The assistantPost output binding sends a message to the assistant and + saves the response in its internal state. + + :param arg_name: The name of binding parameter in the function code. + :param id: The ID of the assistant to update. + :param user_message: The user message that user has entered for + assistant to respond to. + :param model: The OpenAI chat model to use. + :param data_type: Defines how Functions runtime should treat the + parameter value + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_binding( + binding=AssistantPostInput( + name=arg_name, + id=id, + user_message=user_message, + model=model, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + + def embeddings_input(self, + arg_name: str, + input: str, + input_type: InputType, + model: Optional[str] = None, + max_chunk_length: Optional[int] = 8 * 1024, + max_overlap: Optional[int] = 128, + data_type: Optional[ + Union[DataType, str]] = None, + **kwargs) \ + -> Callable[..., Any]: + """ + The embeddings input decorator creates embeddings which will be used to + measure the relatedness of text strings. + + Ref: https://platform.openai.com/docs/guides/embeddings + + :param arg_name: The name of binding parameter in the function code. + :param input: The input source containing the data to generate + embeddings for. + :param input_type: The type of the input. + :param model: The ID of the model to use. + :param max_chunk_length: The maximum number of characters to chunk the + input into. Default value: 8 * 1024 + :param max_overlap: The maximum number of characters to overlap + between chunks. Default value: 128 + :param data_type: Defines how Functions runtime should treat the + parameter value + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_binding( + binding=EmbeddingsInput( + name=arg_name, + input=input, + input_type=input_type, + model=model, + max_chunk_length=max_chunk_length, + max_overlap=max_overlap, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + + def semantic_search_input(self, + arg_name: str, + connection_name: str, + collection: str, + query: Optional[str] = None, + embeddings_model: Optional[OpenAIModels] = OpenAIModels.DefaultEmbeddingsModel, # NoQA + chat_model: Optional[OpenAIModels] = OpenAIModels.DefaultChatModel, # NoQA + system_prompt: Optional[str] = semantic_search_system_prompt, # NoQA + max_knowledge_count: Optional[int] = 1, + data_type: Optional[ + Union[DataType, str]] = None, + **kwargs) \ + -> Callable[..., Any]: + """ + The semantic search feature allows you to import documents into a + vector database using an output binding and query the documents in that + database using an input binding. For example, you can have a function + that imports documents into a vector database and another function that + issues queries to OpenAI using content stored in the vector database as + context (also known as the Retrieval Augmented Generation, or RAG + technique). + + Ref: https://platform.openai.com/docs/guides/embeddings + + :param arg_name: The name of binding parameter in the function code. + :param connection_name: app setting or environment variable which + contains a connection string value. + :param collection: The name of the collection or table to search or + store. + :param query: The semantic query text to use for searching. + :param embeddings_model: The ID of the model to use for embeddings. + The default value is "text-embedding-ada-002". + :param chat_model: The name of the Large Language Model to invoke for + chat responses. The default value is "gpt-3.5-turbo". + :param system_prompt: Optional. The system prompt to use for prompting + the large language model. + :param max_knowledge_count: Optional. The number of knowledge items to + inject into the SystemPrompt. Default value: 1 + :param data_type: Optional. Defines how Functions runtime should treat + the parameter value. Default value: None + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_binding( + binding=SemanticSearchInput( + name=arg_name, + connection_name=connection_name, + collection=collection, + query=query, + embeddings_model=embeddings_model, + chat_model=chat_model, + system_prompt=system_prompt, + max_knowledge_count=max_knowledge_count, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + + def embeddings_store_output(self, + arg_name: str, + input: str, + input_type: InputType, + connection_name: str, + collection: str, + model: Optional[OpenAIModels] = OpenAIModels.DefaultEmbeddingsModel, # NoQA + max_chunk_length: Optional[int] = 8 * 1024, + max_overlap: Optional[int] = 128, + data_type: Optional[ + Union[DataType, str]] = None, + **kwargs) \ + -> Callable[..., Any]: + """ + Supported list of embeddings store is extensible, and more can be + added by authoring a specially crafted NuGet package. Visit the + currently supported vector specific folder for specific usage + information: + + - Azure AI Search + - Azure Data Explorer + - Azure Cosmos DB using MongoDB + + :param arg_name: The name of binding parameter in the function code. + :param input: The input to generate embeddings for. + :param input_type: The type of the input. + :param connection_name: The name of an app setting or environment + variable which contains a connection string value + :param collection: The collection or table to search. + :param model: The ID of the model to use. + :param max_chunk_length: The maximum number of characters to chunk the + input into. + :param max_overlap: The maximum number of characters to overlap between + chunks. + :param data_type: Optional. Defines how Functions runtime should treat + the parameter value. Default value: None + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_binding( + binding=EmbeddingsStoreOutput( + name=arg_name, + input=input, + input_type=input_type, + connection_name=connection_name, + collection=collection, + model=model, + max_chunk_length=max_chunk_length, + max_overlap=max_overlap, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + class SettingsApi(DecoratorApi, ABC): """Interface to extend for using existing settings decorator in diff --git a/azure/functions/decorators/openai.py b/azure/functions/decorators/openai.py new file mode 100644 index 0000000..546a87e --- /dev/null +++ b/azure/functions/decorators/openai.py @@ -0,0 +1,216 @@ +from typing import Optional + +from azure.functions.decorators.constants import (ASSISTANT_SKILLS_TRIGGER, + TEXT_COMPLETION, + ASSISTANT_QUERY, + EMBEDDINGS, EMBEDDINGS_STORE, + ASSISTANT_CREATE, + ASSISTANT_POST, + SEMANTIC_SEARCH) +from azure.functions.decorators.core import Trigger, DataType, InputBinding, \ + OutputBinding +from azure.functions.decorators.utils import StringifyEnum + + +class InputType(StringifyEnum): + + RawText = "raw_text", + FilePath = "file_path" + + +class OpenAIModels(StringifyEnum): + DefaultChatModel = "gpt-3.5-turbo" + DefaultEmbeddingsModel = "text-embedding-ada-002" + + +class AssistantSkillTrigger(Trigger): + + @staticmethod + def get_binding_name() -> str: + return ASSISTANT_SKILLS_TRIGGER + + def __init__(self, + name: str, + function_description: str, + function_name: Optional[str] = None, + parameter_description_json: Optional[str] = None, + model: Optional[OpenAIModels] = OpenAIModels.DefaultChatModel, + data_type: Optional[DataType] = None, + **kwargs): + self.function_description = function_description + self.function_name = function_name + self.parameter_description_json = parameter_description_json + self.model = model + super().__init__(name=name, data_type=data_type) + + +class TextCompletionInput(InputBinding): + + @staticmethod + def get_binding_name() -> str: + return TEXT_COMPLETION + + def __init__(self, + name: str, + prompt: str, + model: Optional[OpenAIModels] = OpenAIModels.DefaultChatModel, + temperature: Optional[str] = "0.5", + top_p: Optional[str] = None, + max_tokens: Optional[str] = "100", + data_type: Optional[DataType] = None, + **kwargs): + self.prompt = prompt + self.model = model + self.temperature = temperature + self.top_p = top_p + self.max_tokens = max_tokens + super().__init__(name=name, data_type=data_type) + + +class AssistantQueryInput(InputBinding): + + @staticmethod + def get_binding_name(): + return ASSISTANT_QUERY + + def __init__(self, + name: str, + id: str, + timestamp_utc: str, + data_type: Optional[DataType] = None, + **kwargs): + self.id = id + self.timestamp_utc = timestamp_utc + super().__init__(name=name, data_type=data_type) + + +class EmbeddingsInput(InputBinding): + + @staticmethod + def get_binding_name() -> str: + return EMBEDDINGS + + def __init__(self, + name: str, + input: str, + input_type: InputType, + model: Optional[str] = None, + max_chunk_length: Optional[int] = 8 * 1024, + max_overlap: Optional[int] = 128, + data_type: Optional[DataType] = None, + **kwargs): + self.name = name + self.input = input + self.input_type = input_type + self.model = model + self.max_chunk_length = max_chunk_length + self.max_overlap = max_overlap + super().__init__(name=name, data_type=data_type) + + +semantic_search_system_prompt = \ + """You are a helpful assistant. You are responding to requests + from a user about internal emails and documents. You can and + should refer to the internal documents to help respond to + requests. If a user makes a request that's not covered by the + internal emails and documents, explain that you don't know the + answer or that you don't have access to the information. + + The following is a list of documents that you can refer to when + answering questions. The documents are in the format + [filename]: [text] and are separated by newlines. If you answer + a question by referencing any of the documents, please cite the + document in your answer. For example, if you answer a question + by referencing info.txt, you should add "Reference: info.txt" + to the end of your answer on a separate line.""" + + +class SemanticSearchInput(InputBinding): + + @staticmethod + def get_binding_name() -> str: + return SEMANTIC_SEARCH + + def __init__(self, + name: str, + connection_name: str, + collection: str, + query: Optional[str] = None, + embeddings_model: Optional[ + OpenAIModels] = OpenAIModels.DefaultEmbeddingsModel, + chat_model: Optional[ + OpenAIModels] = OpenAIModels.DefaultChatModel, + system_prompt: Optional[str] = semantic_search_system_prompt, + max_knowledge_count: Optional[int] = 1, + data_type: Optional[DataType] = None, + **kwargs): + self.name = name + self.connection_name = connection_name + self.collection = collection + self.query = query + self.embeddings_model = embeddings_model + self.chat_model = chat_model + self.system_prompt = system_prompt + self.max_knowledge_count = max_knowledge_count + super().__init__(name=name, data_type=data_type) + + +class AssistantPostInput(InputBinding): + + @staticmethod + def get_binding_name(): + return ASSISTANT_POST + + def __init__(self, name: str, + id: str, + user_message: str, + model: Optional[str] = None, + data_type: Optional[DataType] = None, + **kwargs): + self.name = name + self.id = id + self.user_message = user_message + self.model = model + super().__init__(name=name, data_type=data_type) + + +class EmbeddingsStoreOutput(OutputBinding): + + @staticmethod + def get_binding_name() -> str: + return EMBEDDINGS_STORE + + def __init__(self, + name: str, + input: str, + input_type: InputType, + connection_name: str, + collection: str, + model: Optional[ + OpenAIModels] = OpenAIModels.DefaultEmbeddingsModel, + max_chunk_length: Optional[int] = 8 * 1024, + max_overlap: Optional[int] = 128, + data_type: Optional[DataType] = None, + **kwargs): + self.name = name + self.input = input + self.input_type = input_type + self.connection_name = connection_name + self.collection = collection + self.model = model + self.max_chunk_length = max_chunk_length + self.max_overlap = max_overlap + super().__init__(name=name, data_type=data_type) + + +class AssistantCreateOutput(OutputBinding): + + @staticmethod + def get_binding_name(): + return ASSISTANT_CREATE + + def __init__(self, + name: str, + data_type: Optional[DataType] = None, + **kwargs): + super().__init__(name=name, data_type=data_type) diff --git a/tests/decorators/test_openai.py b/tests/decorators/test_openai.py new file mode 100644 index 0000000..9727890 --- /dev/null +++ b/tests/decorators/test_openai.py @@ -0,0 +1,181 @@ +import unittest + +from azure.functions import DataType +from azure.functions.decorators.core import BindingDirection +from azure.functions.decorators.openai import AssistantSkillTrigger, \ + TextCompletionInput, OpenAIModels, AssistantQueryInput, EmbeddingsInput, \ + AssistantCreateOutput, SemanticSearchInput, EmbeddingsStoreOutput, \ + AssistantPostInput + + +class TestOpenAI(unittest.TestCase): + + def test_assistant_skills_trigger_valid_creation(self): + trigger = AssistantSkillTrigger(name="test", + function_description="description", + function_name="test_function_name", + parameter_description_json="test_json", + model=OpenAIModels.DefaultChatModel, + data_type=DataType.UNDEFINED, + dummy_field="dummy") + self.assertEqual(trigger.get_binding_name(), + "assistantSkillsTrigger") + self.assertEqual( + trigger.get_dict_repr(), {"name": "test", + "functionDescription": "description", + "functionName": "test_function_name", + "parameterDescriptionJson": "test_json", + "model": OpenAIModels.DefaultChatModel, + "dataType": DataType.UNDEFINED, + 'type': 'assistantSkillsTrigger', + 'dummyField': 'dummy', + "direction": BindingDirection.IN, + }) + + def test_text_completion_input_valid_creation(self): + input = TextCompletionInput(name="test", + prompt="test_prompt", + temperature="1", + max_tokens="1", + data_type=DataType.UNDEFINED, + model=OpenAIModels.DefaultChatModel, + dummy_field="dummy") + self.assertEqual(input.get_binding_name(), + "textCompletion") + self.assertEqual(input.get_dict_repr(), + {"name": "test", + "temperature": "1", + "maxTokens": "1", + 'type': 'textCompletion', + "dataType": DataType.UNDEFINED, + "dummyField": "dummy", + "prompt": "test_prompt", + "direction": BindingDirection.IN, + "model": OpenAIModels.DefaultChatModel + }) + + def test_assistant_query_input_valid_creation(self): + input = AssistantQueryInput(name="test", + timestamp_utc="timestamp_utc", + data_type=DataType.UNDEFINED, + id="test_id", + type="assistantQueryInput", + dummy_field="dummy") + self.assertEqual(input.get_binding_name(), + "assistantQuery") + self.assertEqual(input.get_dict_repr(), + {"name": "test", + "timestampUtc": "timestamp_utc", + "dataType": DataType.UNDEFINED, + "direction": BindingDirection.IN, + "type": "assistantQuery", + "id": "test_id", + "dummyField": "dummy" + }) + + def test_embeddings_input_valid_creation(self): + input = EmbeddingsInput(name="test", + data_type=DataType.UNDEFINED, + input="test_input", + input_type="test_input_type", + model="test_model", + max_overlap=1, + max_chunk_length=1, + dummy_field="dummy") + self.assertEqual(input.get_binding_name(), + "embeddings") + self.assertEqual(input.get_dict_repr(), + {"name": "test", + "type": "embeddings", + "dataType": DataType.UNDEFINED, + "input": "test_input", + "inputType": "test_input_type", + "model": "test_model", + "maxOverlap": 1, + "maxChunkLength": 1, + "direction": BindingDirection.IN, + "dummyField": "dummy"}) + + def test_assistant_create_output_valid_creation(self): + output = AssistantCreateOutput(name="test", + data_type=DataType.UNDEFINED) + self.assertEqual(output.get_binding_name(), + "assistantCreate") + self.assertEqual(output.get_dict_repr(), + {"name": "test", + "dataType": DataType.UNDEFINED, + "direction": BindingDirection.OUT, + "type": "assistantCreate"}) + + def test_assistant_post_input_valid_creation(self): + input = AssistantPostInput(name="test", + id="test_id", + model="test_model", + user_message="test_message", + data_type=DataType.UNDEFINED, + dummy_field="dummy") + self.assertEqual(input.get_binding_name(), + "assistantPost") + self.assertEqual(input.get_dict_repr(), + {"name": "test", + "id": "test_id", + "model": "test_model", + "userMessage": "test_message", + "dataType": DataType.UNDEFINED, + "direction": BindingDirection.IN, + "dummyField": "dummy", + "type": "assistantPost"}) + + def test_semantic_search_input_valid_creation(self): + input = SemanticSearchInput(name="test", + data_type=DataType.UNDEFINED, + chat_model=OpenAIModels.DefaultChatModel, + embeddings_model=OpenAIModels.DefaultEmbeddingsModel, # NoQA + collection="test_collection", + connection_name="test_connection", + system_prompt="test_prompt", + query="test_query", + max_knowledge_count=1, + dummy_field="dummy_field") + self.assertEqual(input.get_binding_name(), + "semanticSearch") + self.assertEqual(input.get_dict_repr(), + {"name": "test", + "dataType": DataType.UNDEFINED, + "direction": BindingDirection.IN, + "dummyField": "dummy_field", + "chatModel": OpenAIModels.DefaultChatModel, + "embeddingsModel": OpenAIModels.DefaultEmbeddingsModel, # NoQA + "type": "semanticSearch", + "collection": "test_collection", + "connectionName": "test_connection", + "systemPrompt": "test_prompt", + "maxKnowledgeCount": 1, + "query": "test_query"}) + + def test_embeddings_store_output_valid_creation(self): + output = EmbeddingsStoreOutput(name="test", + data_type=DataType.UNDEFINED, + input="test_input", + input_type="test_input_type", + connection_name="test_connection", + max_overlap=1, + max_chunk_length=1, + collection="test_collection", + model=OpenAIModels.DefaultChatModel, + dummy_field="dummy_field") + self.assertEqual(output.get_binding_name(), + "embeddingsStore") + self.assertEqual(output.get_dict_repr(), + {"name": "test", + "dataType": DataType.UNDEFINED, + "direction": BindingDirection.OUT, + "dummyField": "dummy_field", + "input": "test_input", + "inputType": "test_input_type", + "collection": "test_collection", + "model": OpenAIModels.DefaultChatModel, + "connectionName": "test_connection", + "maxOverlap": 1, + "maxChunkLength": 1, + "type": "embeddingsStore"}) From 3a0797131fa87424ee912b225f60624785f98999 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Mon, 13 May 2024 10:19:25 -0500 Subject: [PATCH 08/34] Update __init__.py (#218) --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 7dd45df..4a35da6 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -97,4 +97,4 @@ __all__ = ( 'HttpMethod' ) -__version__ = '1.20.0b1' +__version__ = '1.20.0b2' From de147728b714bcbb037c7bc3b5c2760e08317505 Mon Sep 17 00:00:00 2001 From: wangbill <12449837+YunchuWang@users.noreply.github.com> Date: Wed, 15 May 2024 14:31:11 -0700 Subject: [PATCH 09/34] add source to blob_trigger (#196) * add source to blob_trigger * fix * test fix * remove source as str --------- Co-authored-by: hallvictoria <59299039+hallvictoria@users.noreply.github.com> --- azure/functions/__init__.py | 5 ++-- azure/functions/decorators/__init__.py | 6 +++-- azure/functions/decorators/blob.py | 6 +++-- azure/functions/decorators/core.py | 8 +++++++ azure/functions/decorators/function_app.py | 11 ++++++++- docs/ProgModelSpec.pyi | 9 +++++++- tests/decorators/test_blob.py | 27 ++++++++++++++++++++-- tests/decorators/test_decorators.py | 8 ++++++- 8 files changed, 69 insertions(+), 11 deletions(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 4a35da6..15cf667 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -10,7 +10,7 @@ from .decorators import (FunctionApp, Function, Blueprint, DecoratorApi, DataType, AuthLevel, Cardinality, AccessRights, HttpMethod, AsgiFunctionApp, WsgiFunctionApp, - ExternalHttpFunctionApp) + ExternalHttpFunctionApp, BlobSource) from ._durable_functions import OrchestrationContext, EntityContext from .decorators.function_app import (FunctionRegister, TriggerApi, BindingApi, SettingsApi) @@ -94,7 +94,8 @@ __all__ = ( 'AuthLevel', 'Cardinality', 'AccessRights', - 'HttpMethod' + 'HttpMethod', + 'BlobSource' ) __version__ = '1.20.0b2' diff --git a/azure/functions/decorators/__init__.py b/azure/functions/decorators/__init__.py index 90ff9ac..be7ff99 100644 --- a/azure/functions/decorators/__init__.py +++ b/azure/functions/decorators/__init__.py @@ -3,7 +3,8 @@ from .core import Cardinality, AccessRights from .function_app import FunctionApp, Function, DecoratorApi, DataType, \ AuthLevel, Blueprint, ExternalHttpFunctionApp, AsgiFunctionApp, \ - WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, SettingsApi + WsgiFunctionApp, FunctionRegister, TriggerApi, BindingApi, \ + SettingsApi, BlobSource from .http import HttpMethod __all__ = [ @@ -22,5 +23,6 @@ __all__ = [ 'AuthLevel', 'Cardinality', 'AccessRights', - 'HttpMethod' + 'HttpMethod', + 'BlobSource' ] diff --git a/azure/functions/decorators/blob.py b/azure/functions/decorators/blob.py index 35d5678..0f38603 100644 --- a/azure/functions/decorators/blob.py +++ b/azure/functions/decorators/blob.py @@ -3,8 +3,8 @@ from typing import Optional from azure.functions.decorators.constants import BLOB_TRIGGER, BLOB -from azure.functions.decorators.core import Trigger, OutputBinding, DataType, \ - InputBinding +from azure.functions.decorators.core import BlobSource, Trigger, \ + OutputBinding, DataType, InputBinding class BlobTrigger(Trigger): @@ -12,10 +12,12 @@ class BlobTrigger(Trigger): name: str, path: str, connection: str, + source: BlobSource, data_type: Optional[DataType] = None, **kwargs): self.path = path self.connection = connection + self.source = source super().__init__(name=name, data_type=data_type) @staticmethod diff --git a/azure/functions/decorators/core.py b/azure/functions/decorators/core.py index 81e2ff9..7aa9d12 100644 --- a/azure/functions/decorators/core.py +++ b/azure/functions/decorators/core.py @@ -65,6 +65,14 @@ class AccessRights(StringifyEnum): and all related message handling. """ +class BlobSource(StringifyEnum): + """Source of the blob trigger.""" + EVENT_GRID = "EventGrid" + """Event Grid is the source of the blob trigger.""" + LOGS_AND_CONTAINER_SCAN = "LogsAndContainerScan" + """Standard polling mechanism to detect changes in the container.""" + + class Binding(ABC): """Abstract binding class which captures common attributes and functions. :meth:`get_dict_repr` can auto generate the function.json for diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index fe17ceb..3807c67 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -11,7 +11,7 @@ from typing import Any, Callable, Dict, List, Optional, Union, \ from azure.functions.decorators.blob import BlobTrigger, BlobInput, BlobOutput from azure.functions.decorators.core import Binding, Trigger, DataType, \ - AuthLevel, SCRIPT_FILE_NAME, Cardinality, AccessRights, Setting + AuthLevel, SCRIPT_FILE_NAME, Cardinality, AccessRights, Setting, BlobSource from azure.functions.decorators.cosmosdb import CosmosDBTrigger, \ CosmosDBOutput, CosmosDBInput, CosmosDBTriggerV3, CosmosDBInputV3, \ CosmosDBOutputV3 @@ -1114,6 +1114,8 @@ class TriggerApi(DecoratorApi, ABC): arg_name: str, path: str, connection: str, + source: BlobSource = + BlobSource.LOGS_AND_CONTAINER_SCAN, data_type: Optional[DataType] = None, **kwargs) -> Callable[..., Any]: """ @@ -1131,6 +1133,12 @@ class TriggerApi(DecoratorApi, ABC): :param path: The path to the blob. :param connection: The name of an app setting or setting collection that specifies how to connect to Azure Blobs. + :param source: Sets the source of the triggering event. + Use EventGrid for an Event Grid-based blob trigger, + which provides much lower latency. + The default is LogsAndContainerScan, + which uses the standard polling mechanism to detect changes + in the container. :param data_type: Defines how Functions runtime should treat the parameter value. :param kwargs: Keyword arguments for specifying additional binding @@ -1147,6 +1155,7 @@ class TriggerApi(DecoratorApi, ABC): name=arg_name, path=path, connection=connection, + source=source, data_type=parse_singular_param_to_enum(data_type, DataType), **kwargs)) diff --git a/docs/ProgModelSpec.pyi b/docs/ProgModelSpec.pyi index a01d816..14fe202 100644 --- a/docs/ProgModelSpec.pyi +++ b/docs/ProgModelSpec.pyi @@ -4,7 +4,7 @@ from abc import ABC from typing import Callable, Dict, List, Optional, Union, Iterable from azure.functions import AsgiMiddleware, WsgiMiddleware -from azure.functions.decorators.core import Binding, Trigger, DataType, \ +from azure.functions.decorators.core import Binding, BlobSource, Trigger, DataType, \ AuthLevel, Cardinality, AccessRights, Setting from azure.functions.decorators.function_app import FunctionBuilder, SettingsApi from azure.functions.decorators.http import HttpMethod @@ -495,6 +495,7 @@ class TriggerApi(DecoratorApi, ABC): arg_name: str, path: str, connection: str, + source: BlobSource = BlobSource.LOGS_AND_CONTAINER_SCAN, data_type: Optional[DataType] = None, **kwargs) -> Callable: """ @@ -512,6 +513,12 @@ class TriggerApi(DecoratorApi, ABC): :param path: The path to the blob. :param connection: The name of an app setting or setting collection that specifies how to connect to Azure Blobs. + :param source: Sets the source of the triggering event. + Use EventGrid for an Event Grid-based blob trigger, + which provides much lower latency. + The default is LogsAndContainerScan, + which uses the standard polling mechanism to detect changes + in the container. :param data_type: Defines how Functions runtime should treat the parameter value. :param kwargs: Keyword arguments for specifying additional binding diff --git a/tests/decorators/test_blob.py b/tests/decorators/test_blob.py index 9d6154e..f77f731 100644 --- a/tests/decorators/test_blob.py +++ b/tests/decorators/test_blob.py @@ -3,14 +3,16 @@ import unittest from azure.functions.decorators.blob import BlobTrigger, BlobOutput, BlobInput -from azure.functions.decorators.core import BindingDirection, DataType +from azure.functions.decorators.core import BindingDirection, BlobSource, \ + DataType class TestBlob(unittest.TestCase): - def test_blob_trigger_valid_creation(self): + def test_blob_trigger_creation_with_source_as_string(self): trigger = BlobTrigger(name="req", path="dummy_path", connection="dummy_connection", + source=BlobSource.EVENT_GRID, data_type=DataType.UNDEFINED, dummy_field="dummy") @@ -22,6 +24,27 @@ class TestBlob(unittest.TestCase): "name": "req", "dataType": DataType.UNDEFINED, "path": "dummy_path", + 'source': BlobSource.EVENT_GRID, + "connection": "dummy_connection" + }) + + def test_blob_trigger_creation_with_source_as_enum(self): + trigger = BlobTrigger(name="req", + path="dummy_path", + connection="dummy_connection", + source=BlobSource.EVENT_GRID, + data_type=DataType.UNDEFINED, + dummy_field="dummy") + + self.assertEqual(trigger.get_binding_name(), "blobTrigger") + self.assertEqual(trigger.get_dict_repr(), { + "type": "blobTrigger", + "direction": BindingDirection.IN, + 'dummyField': 'dummy', + "name": "req", + "dataType": DataType.UNDEFINED, + "path": "dummy_path", + 'source': BlobSource.EVENT_GRID, "connection": "dummy_connection" }) diff --git a/tests/decorators/test_decorators.py b/tests/decorators/test_decorators.py index f31a42b..115c857 100644 --- a/tests/decorators/test_decorators.py +++ b/tests/decorators/test_decorators.py @@ -8,7 +8,7 @@ from azure.functions.decorators.constants import TIMER_TRIGGER, HTTP_TRIGGER, \ BLOB_TRIGGER, EVENT_GRID_TRIGGER, EVENT_GRID, TABLE, WARMUP_TRIGGER, \ SQL, SQL_TRIGGER, ORCHESTRATION_TRIGGER, ACTIVITY_TRIGGER, \ ENTITY_TRIGGER, DURABLE_CLIENT -from azure.functions.decorators.core import DataType, AuthLevel, \ +from azure.functions.decorators.core import BlobSource, DataType, AuthLevel, \ BindingDirection, AccessRights, Cardinality from azure.functions.decorators.function_app import FunctionApp from azure.functions.decorators.http import HttpTrigger, HttpMethod @@ -1569,6 +1569,8 @@ class TestFunctionsApp(unittest.TestCase): "type": BLOB_TRIGGER, "name": "req", "path": "dummy_path", + "source": + BlobSource.LOGS_AND_CONTAINER_SCAN, "connection": "dummy_conn" }]}) @@ -1593,6 +1595,7 @@ class TestFunctionsApp(unittest.TestCase): "type": BLOB_TRIGGER, "name": "req", "path": "dummy_path", + "source": BlobSource.LOGS_AND_CONTAINER_SCAN, "connection": "dummy_conn" }) @@ -1601,6 +1604,7 @@ class TestFunctionsApp(unittest.TestCase): @app.blob_trigger(arg_name="req", path="dummy_path", data_type=DataType.STRING, + source=BlobSource.EVENT_GRID, connection="dummy_conn") @app.blob_input(arg_name="file", path="dummy_in_path", connection="dummy_in_conn", @@ -1622,6 +1626,7 @@ class TestFunctionsApp(unittest.TestCase): "type": BLOB_TRIGGER, "name": "req", "path": "dummy_path", + "source": BlobSource.EVENT_GRID, "connection": "dummy_conn" }) @@ -1660,6 +1665,7 @@ class TestFunctionsApp(unittest.TestCase): "type": BLOB_TRIGGER, "name": "req", "path": "dummy_path", + "source": BlobSource.LOGS_AND_CONTAINER_SCAN, "connection": "dummy_conn" }) From 3e87e817971581f03205404946410cb209fbc4cc Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Wed, 15 May 2024 16:48:09 -0500 Subject: [PATCH 10/34] Update __init__.py (#219) --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 15cf667..2238a22 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -98,4 +98,4 @@ __all__ = ( 'BlobSource' ) -__version__ = '1.20.0b2' +__version__ = '1.20.0b3' From bdeb2c2e29dd491129784ccb5cf5b5371b1ce286 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Thu, 30 May 2024 11:43:20 -0500 Subject: [PATCH 11/34] Optional source param for blob trigger (#220) * making source optional param * fixed tests * added tests --- azure/functions/decorators/blob.py | 2 +- azure/functions/decorators/function_app.py | 4 +-- docs/ProgModelSpec.pyi | 2 +- tests/decorators/test_blob.py | 38 ++++++++++++++++++++++ tests/decorators/test_decorators.py | 4 --- 5 files changed, 42 insertions(+), 8 deletions(-) diff --git a/azure/functions/decorators/blob.py b/azure/functions/decorators/blob.py index 0f38603..1a2d412 100644 --- a/azure/functions/decorators/blob.py +++ b/azure/functions/decorators/blob.py @@ -12,7 +12,7 @@ class BlobTrigger(Trigger): name: str, path: str, connection: str, - source: BlobSource, + source: Optional[BlobSource] = None, data_type: Optional[DataType] = None, **kwargs): self.path = path diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 3807c67..b3d2046 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -1114,8 +1114,8 @@ class TriggerApi(DecoratorApi, ABC): arg_name: str, path: str, connection: str, - source: BlobSource = - BlobSource.LOGS_AND_CONTAINER_SCAN, + source: Optional[BlobSource] = + None, data_type: Optional[DataType] = None, **kwargs) -> Callable[..., Any]: """ diff --git a/docs/ProgModelSpec.pyi b/docs/ProgModelSpec.pyi index 14fe202..dd7a5e9 100644 --- a/docs/ProgModelSpec.pyi +++ b/docs/ProgModelSpec.pyi @@ -495,7 +495,7 @@ class TriggerApi(DecoratorApi, ABC): arg_name: str, path: str, connection: str, - source: BlobSource = BlobSource.LOGS_AND_CONTAINER_SCAN, + source: Optional[BlobSource] = None, data_type: Optional[DataType] = None, **kwargs) -> Callable: """ diff --git a/tests/decorators/test_blob.py b/tests/decorators/test_blob.py index f77f731..f8712b9 100644 --- a/tests/decorators/test_blob.py +++ b/tests/decorators/test_blob.py @@ -8,6 +8,44 @@ from azure.functions.decorators.core import BindingDirection, BlobSource, \ class TestBlob(unittest.TestCase): + def test_blob_trigger_creation_with_no_source(self): + trigger = BlobTrigger(name="req", + path="dummy_path", + connection="dummy_connection", + data_type=DataType.UNDEFINED, + dummy_field="dummy") + + self.assertEqual(trigger.get_binding_name(), "blobTrigger") + self.assertEqual(trigger.get_dict_repr(), { + "type": "blobTrigger", + "direction": BindingDirection.IN, + 'dummyField': 'dummy', + "name": "req", + "dataType": DataType.UNDEFINED, + "path": "dummy_path", + "connection": "dummy_connection" + }) + + def test_blob_trigger_creation_with_default_specified_source(self): + trigger = BlobTrigger(name="req", + path="dummy_path", + connection="dummy_connection", + source=BlobSource.LOGS_AND_CONTAINER_SCAN, + data_type=DataType.UNDEFINED, + dummy_field="dummy") + + self.assertEqual(trigger.get_binding_name(), "blobTrigger") + self.assertEqual(trigger.get_dict_repr(), { + "type": "blobTrigger", + "direction": BindingDirection.IN, + 'dummyField': 'dummy', + "name": "req", + "dataType": DataType.UNDEFINED, + "path": "dummy_path", + 'source': BlobSource.LOGS_AND_CONTAINER_SCAN, + "connection": "dummy_connection" + }) + def test_blob_trigger_creation_with_source_as_string(self): trigger = BlobTrigger(name="req", path="dummy_path", diff --git a/tests/decorators/test_decorators.py b/tests/decorators/test_decorators.py index 115c857..1bd16de 100644 --- a/tests/decorators/test_decorators.py +++ b/tests/decorators/test_decorators.py @@ -1569,8 +1569,6 @@ class TestFunctionsApp(unittest.TestCase): "type": BLOB_TRIGGER, "name": "req", "path": "dummy_path", - "source": - BlobSource.LOGS_AND_CONTAINER_SCAN, "connection": "dummy_conn" }]}) @@ -1595,7 +1593,6 @@ class TestFunctionsApp(unittest.TestCase): "type": BLOB_TRIGGER, "name": "req", "path": "dummy_path", - "source": BlobSource.LOGS_AND_CONTAINER_SCAN, "connection": "dummy_conn" }) @@ -1665,7 +1662,6 @@ class TestFunctionsApp(unittest.TestCase): "type": BLOB_TRIGGER, "name": "req", "path": "dummy_path", - "source": BlobSource.LOGS_AND_CONTAINER_SCAN, "connection": "dummy_conn" }) From f319b33e26587d6120ad5d44639faefe046efce2 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Fri, 7 Jun 2024 13:02:53 -0500 Subject: [PATCH 12/34] Fix OpenAI decorators assistant skill trigger type typo (#224) * changing default {} to None * renamed type to singular * clean up past commits * missed references * missed references --------- Co-authored-by: Victoria Hall --- azure/functions/decorators/constants.py | 2 +- azure/functions/decorators/openai.py | 4 ++-- tests/decorators/test_openai.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/azure/functions/decorators/constants.py b/azure/functions/decorators/constants.py index 0948bc5..2787cb2 100644 --- a/azure/functions/decorators/constants.py +++ b/azure/functions/decorators/constants.py @@ -33,7 +33,7 @@ ORCHESTRATION_TRIGGER = "orchestrationTrigger" ACTIVITY_TRIGGER = "activityTrigger" ENTITY_TRIGGER = "entityTrigger" DURABLE_CLIENT = "durableClient" -ASSISTANT_SKILLS_TRIGGER = "assistantSkillsTrigger" +ASSISTANT_SKILL_TRIGGER = "assistantSkillTrigger" TEXT_COMPLETION = "textCompletion" ASSISTANT_QUERY = "assistantQuery" EMBEDDINGS = "embeddings" diff --git a/azure/functions/decorators/openai.py b/azure/functions/decorators/openai.py index 546a87e..df459c1 100644 --- a/azure/functions/decorators/openai.py +++ b/azure/functions/decorators/openai.py @@ -1,6 +1,6 @@ from typing import Optional -from azure.functions.decorators.constants import (ASSISTANT_SKILLS_TRIGGER, +from azure.functions.decorators.constants import (ASSISTANT_SKILL_TRIGGER, TEXT_COMPLETION, ASSISTANT_QUERY, EMBEDDINGS, EMBEDDINGS_STORE, @@ -27,7 +27,7 @@ class AssistantSkillTrigger(Trigger): @staticmethod def get_binding_name() -> str: - return ASSISTANT_SKILLS_TRIGGER + return ASSISTANT_SKILL_TRIGGER def __init__(self, name: str, diff --git a/tests/decorators/test_openai.py b/tests/decorators/test_openai.py index 9727890..f2ebdac 100644 --- a/tests/decorators/test_openai.py +++ b/tests/decorators/test_openai.py @@ -10,7 +10,7 @@ from azure.functions.decorators.openai import AssistantSkillTrigger, \ class TestOpenAI(unittest.TestCase): - def test_assistant_skills_trigger_valid_creation(self): + def test_assistant_skill_trigger_valid_creation(self): trigger = AssistantSkillTrigger(name="test", function_description="description", function_name="test_function_name", @@ -19,7 +19,7 @@ class TestOpenAI(unittest.TestCase): data_type=DataType.UNDEFINED, dummy_field="dummy") self.assertEqual(trigger.get_binding_name(), - "assistantSkillsTrigger") + "assistantSkillTrigger") self.assertEqual( trigger.get_dict_repr(), {"name": "test", "functionDescription": "description", @@ -27,7 +27,7 @@ class TestOpenAI(unittest.TestCase): "parameterDescriptionJson": "test_json", "model": OpenAIModels.DefaultChatModel, "dataType": DataType.UNDEFINED, - 'type': 'assistantSkillsTrigger', + 'type': 'assistantSkillTrigger', 'dummyField': 'dummy', "direction": BindingDirection.IN, }) From 753742d97c03226a8c65102f71b2230ef6d42b94 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Fri, 7 Jun 2024 13:19:25 -0500 Subject: [PATCH 13/34] Update __init__.py (#222) --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 2238a22..d2a1533 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -98,4 +98,4 @@ __all__ = ( 'BlobSource' ) -__version__ = '1.20.0b3' +__version__ = '1.20.0b4' From fc78a0691d6da4b9860346a79f9f30fb87ba135f Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Thu, 13 Jun 2024 13:07:03 -0500 Subject: [PATCH 14/34] Update __init__.py (#225) --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index d2a1533..57511eb 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -98,4 +98,4 @@ __all__ = ( 'BlobSource' ) -__version__ = '1.20.0b4' +__version__ = '1.20.0' From f0693442e51221174b8e10c8bc8884113aa31964 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Tue, 18 Jun 2024 14:38:57 -0500 Subject: [PATCH 15/34] update to 1ES PT (#221) * code mirror template for library * official build, public build, tests --------- Co-authored-by: Victoria Hall --- eng/ci/code-mirror.yml | 18 ++++++++++++++ eng/ci/official-build.yml | 50 ++++++++++++++++++++++++++++++++++++++ eng/ci/public-build.yml | 43 ++++++++++++++++++++++++++++++++ eng/templates/build.yml | 21 ++++++++++++++++ eng/templates/ci-tests.yml | 32 ++++++++++++++++++++++++ 5 files changed, 164 insertions(+) create mode 100644 eng/ci/code-mirror.yml create mode 100644 eng/ci/official-build.yml create mode 100644 eng/ci/public-build.yml create mode 100644 eng/templates/build.yml create mode 100644 eng/templates/ci-tests.yml diff --git a/eng/ci/code-mirror.yml b/eng/ci/code-mirror.yml new file mode 100644 index 0000000..ee145b2 --- /dev/null +++ b/eng/ci/code-mirror.yml @@ -0,0 +1,18 @@ +trigger: + branches: + include: + - dev + - release/* + +resources: + repositories: + - repository: eng + type: git + name: engineering + ref: refs/tags/release + +variables: + - template: ci/variables/cfs.yml@eng + +extends: + template: ci/code-mirror.yml@eng diff --git a/eng/ci/official-build.yml b/eng/ci/official-build.yml new file mode 100644 index 0000000..2723cd5 --- /dev/null +++ b/eng/ci/official-build.yml @@ -0,0 +1,50 @@ +trigger: + batch: true + branches: + include: + - dev + - release/* + +# CI only, does not trigger on PRs. +pr: none + +schedules: + - cron: '0 0 * * MON' + displayName: At 12:00 AM, only on Monday + branches: + include: + - dev + always: true + +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.Official.PipelineTemplate.yml@1es + parameters: + pool: + name: 1es-pool-azfunc + image: 1es-windows-2022 + os: windows + + stages: + - stage: Build + jobs: + - template: /eng/templates/build.yml@self + + - stage: RunTests + dependsOn: Build + jobs: + - template: /eng/templates/ci-tests.yml@self diff --git a/eng/ci/public-build.yml b/eng/ci/public-build.yml new file mode 100644 index 0000000..cac005a --- /dev/null +++ b/eng/ci/public-build.yml @@ -0,0 +1,43 @@ +trigger: + batch: true + branches: + include: + - dev + +pr: + branches: + include: + - dev + +schedules: + - cron: '0 0 * * MON' + displayName: At 12:00 AM, only on Monday + branches: + include: + - dev + always: true + +resources: + repositories: + - repository: 1es + type: git + name: 1ESPipelineTemplates/1ESPipelineTemplates + ref: refs/tags/release + +extends: + template: v1/1ES.Unofficial.PipelineTemplate.yml@1es + parameters: + pool: + name: 1es-pool-azfunc-public + image: 1es-windows-2022 + os: windows + + stages: + - stage: Build + jobs: + - template: /eng/templates/build.yml@self + + - stage: RunTests + dependsOn: Build + jobs: + - template: /eng/templates/ci-tests.yml@self diff --git a/eng/templates/build.yml b/eng/templates/build.yml new file mode 100644 index 0000000..58259d4 --- /dev/null +++ b/eng/templates/build.yml @@ -0,0 +1,21 @@ +jobs: + - job: "Build" + displayName: 'Build Python SDK' + + pool: + name: 1es-pool-azfunc-public + image: 1es-windows-2022 + os: windows + + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: "3.11" + - bash: | + python --version + displayName: 'Check python version' + - bash: | + python -m pip install -U pip + pip install twine wheel + python setup.py sdist bdist_wheel + displayName: 'Build Python SDK' diff --git a/eng/templates/ci-tests.yml b/eng/templates/ci-tests.yml new file mode 100644 index 0000000..33133d7 --- /dev/null +++ b/eng/templates/ci-tests.yml @@ -0,0 +1,32 @@ +jobs: + - job: "TestPython" + displayName: "Run Python SDK Unit Tests" + + pool: + name: 1es-pool-azfunc + image: 1es-ubuntu-22.04 + os: linux + + strategy: + matrix: + python-37: + PYTHON_VERSION: '3.7' + python-38: + PYTHON_VERSION: '3.8' + python-39: + PYTHON_VERSION: '3.9' + python-310: + PYTHON_VERSION: '3.10' + python-311: + PYTHON_VERSION: '3.11' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + - bash: | + python -m pip install --upgrade pip + python -m pip install -U -e .[dev] + displayName: 'Install dependencies' + - bash: | + python -m pytest --cache-clear --cov=./azure --cov-report=xml --cov-branch tests + displayName: 'Test with pytest' \ No newline at end of file From 67235c0d560324cc3ca68d5d8509fec7849922f5 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Wed, 19 Jun 2024 11:38:57 -0500 Subject: [PATCH 16/34] removed pool from build and unit tests (#227) Co-authored-by: Victoria Hall --- eng/templates/build.yml | 5 ----- eng/templates/ci-tests.yml | 5 ----- 2 files changed, 10 deletions(-) diff --git a/eng/templates/build.yml b/eng/templates/build.yml index 58259d4..faf0148 100644 --- a/eng/templates/build.yml +++ b/eng/templates/build.yml @@ -2,11 +2,6 @@ jobs: - job: "Build" displayName: 'Build Python SDK' - pool: - name: 1es-pool-azfunc-public - image: 1es-windows-2022 - os: windows - steps: - task: UsePythonVersion@0 inputs: diff --git a/eng/templates/ci-tests.yml b/eng/templates/ci-tests.yml index 33133d7..2bee12f 100644 --- a/eng/templates/ci-tests.yml +++ b/eng/templates/ci-tests.yml @@ -2,11 +2,6 @@ jobs: - job: "TestPython" displayName: "Run Python SDK Unit Tests" - pool: - name: 1es-pool-azfunc - image: 1es-ubuntu-22.04 - os: linux - strategy: matrix: python-37: From b2f48a59a03a2d492e9627e9463ed6aaa2799a06 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Wed, 26 Jun 2024 15:16:02 -0500 Subject: [PATCH 17/34] sync with ADO changes --- eng/ci/official-build.yml | 4 +-- eng/ci/public-build.yml | 9 +++++-- eng/templates/jobs/build.yml | 16 +++++++++++ eng/templates/jobs/ci-tests.yml | 27 +++++++++++++++++++ .../official/jobs/build-artifacts.yml | 23 ++++++++++++++++ 5 files changed, 75 insertions(+), 4 deletions(-) create mode 100644 eng/templates/jobs/build.yml create mode 100644 eng/templates/jobs/ci-tests.yml create mode 100644 eng/templates/official/jobs/build-artifacts.yml diff --git a/eng/ci/official-build.yml b/eng/ci/official-build.yml index 2723cd5..d4f4e9a 100644 --- a/eng/ci/official-build.yml +++ b/eng/ci/official-build.yml @@ -42,9 +42,9 @@ extends: stages: - stage: Build jobs: - - template: /eng/templates/build.yml@self + - template: /eng/templates/official/jobs/build-artifacts.yml@self - stage: RunTests dependsOn: Build jobs: - - template: /eng/templates/ci-tests.yml@self + - template: /eng/templates/jobs/ci-tests.yml@self diff --git a/eng/ci/public-build.yml b/eng/ci/public-build.yml index cac005a..d3caca8 100644 --- a/eng/ci/public-build.yml +++ b/eng/ci/public-build.yml @@ -31,13 +31,18 @@ extends: name: 1es-pool-azfunc-public image: 1es-windows-2022 os: windows + sdl: + codeql: + compiled: + enabled: true # still only runs for default branch + runSourceLanguagesInSourceAnalysis: true stages: - stage: Build jobs: - - template: /eng/templates/build.yml@self + - template: /eng/templates/jobs/build.yml@self - stage: RunTests dependsOn: Build jobs: - - template: /eng/templates/ci-tests.yml@self + - template: /eng/templates/jobs/ci-tests.yml@self diff --git a/eng/templates/jobs/build.yml b/eng/templates/jobs/build.yml new file mode 100644 index 0000000..faf0148 --- /dev/null +++ b/eng/templates/jobs/build.yml @@ -0,0 +1,16 @@ +jobs: + - job: "Build" + displayName: 'Build Python SDK' + + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: "3.11" + - bash: | + python --version + displayName: 'Check python version' + - bash: | + python -m pip install -U pip + pip install twine wheel + python setup.py sdist bdist_wheel + displayName: 'Build Python SDK' diff --git a/eng/templates/jobs/ci-tests.yml b/eng/templates/jobs/ci-tests.yml new file mode 100644 index 0000000..2bee12f --- /dev/null +++ b/eng/templates/jobs/ci-tests.yml @@ -0,0 +1,27 @@ +jobs: + - job: "TestPython" + displayName: "Run Python SDK Unit Tests" + + strategy: + matrix: + python-37: + PYTHON_VERSION: '3.7' + python-38: + PYTHON_VERSION: '3.8' + python-39: + PYTHON_VERSION: '3.9' + python-310: + PYTHON_VERSION: '3.10' + python-311: + PYTHON_VERSION: '3.11' + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(PYTHON_VERSION) + - bash: | + python -m pip install --upgrade pip + python -m pip install -U -e .[dev] + displayName: 'Install dependencies' + - bash: | + python -m pytest --cache-clear --cov=./azure --cov-report=xml --cov-branch tests + displayName: 'Test with pytest' \ No newline at end of file diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml new file mode 100644 index 0000000..e2e6e01 --- /dev/null +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -0,0 +1,23 @@ +jobs: + - job: "Build" + displayName: 'Build Python SDK' + + templateContext: + outputParentDirectory: $(Build.ArtifactStagingDirectory) + outputs: + - output: pipelineArtifact + targetPath: $(Build.SourcesDirectory) + artifactName: "azure-functions" + + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: "3.11" + - bash: | + python --version + displayName: 'Check python version' + - bash: | + python -m pip install -U pip + pip install twine wheel + python setup.py sdist bdist_wheel + displayName: 'Build Python SDK' From abecd584487a4495eca4b10b59a22f5cb41e77d6 Mon Sep 17 00:00:00 2001 From: gavin-aguiar <80794152+gavin-aguiar@users.noreply.github.com> Date: Mon, 1 Jul 2024 16:43:42 -0500 Subject: [PATCH 18/34] Added eventgrid connection property (#229) --- azure/functions/decorators/eventgrid.py | 17 +++++++- azure/functions/decorators/function_app.py | 8 +++- tests/decorators/test_eventgrid.py | 47 +++++++++++++++++----- 3 files changed, 59 insertions(+), 13 deletions(-) diff --git a/azure/functions/decorators/eventgrid.py b/azure/functions/decorators/eventgrid.py index 47cda37..9239992 100644 --- a/azure/functions/decorators/eventgrid.py +++ b/azure/functions/decorators/eventgrid.py @@ -27,10 +27,23 @@ class EventGridOutput(OutputBinding): def __init__(self, name: str, - topic_endpoint_uri: str, - topic_key_setting: str, + topic_endpoint_uri: Optional[str] = None, + topic_key_setting: Optional[str] = None, + connection: Optional[str] = None, data_type: Optional[DataType] = None, **kwargs): + if (connection is not None and ( + topic_endpoint_uri is not None + or topic_key_setting is not None)) or \ + (connection is None and ( + topic_endpoint_uri is None + or topic_key_setting is None)): + raise ValueError( + "Specify either the 'Connection' property or both " + "'TopicKeySetting' and 'TopicEndpointUri' properties," + " but not both.") + self.topic_endpoint_uri = topic_endpoint_uri self.topic_key_setting = topic_key_setting + self.connection = connection super().__init__(name=name, data_type=data_type) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index b3d2046..a716087 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -2138,8 +2138,9 @@ class BindingApi(DecoratorApi, ABC): def event_grid_output(self, arg_name: str, - topic_endpoint_uri: str, - topic_key_setting: str, + topic_endpoint_uri: Optional[str] = None, + topic_key_setting: Optional[str] = None, + connection: Optional[str] = None, data_type: Optional[ Union[DataType, str]] = None, **kwargs) -> Callable[..., Any]: @@ -2164,6 +2165,8 @@ class BindingApi(DecoratorApi, ABC): contains the URI for the custom topic. :param topic_key_setting: The name of an app setting that contains an access key for the custom topic. + :param connection: The value of the common prefix for the setting that + contains the topic endpoint URI. :return: Decorator function. """ @@ -2175,6 +2178,7 @@ class BindingApi(DecoratorApi, ABC): name=arg_name, topic_endpoint_uri=topic_endpoint_uri, topic_key_setting=topic_key_setting, + connection=connection, data_type=parse_singular_param_to_enum(data_type, DataType), **kwargs)) diff --git a/tests/decorators/test_eventgrid.py b/tests/decorators/test_eventgrid.py index d77fe97..1580000 100644 --- a/tests/decorators/test_eventgrid.py +++ b/tests/decorators/test_eventgrid.py @@ -27,17 +27,46 @@ class TestEventGrid(unittest.TestCase): output = EventGridOutput(name="res", topic_endpoint_uri="dummy_topic_endpoint_uri", topic_key_setting="dummy_topic_key_setting", - connection="dummy_connection", data_type=DataType.UNDEFINED, dummy_field="dummy") self.assertEqual(output.get_binding_name(), "eventGrid") self.assertEqual(output.get_dict_repr(), - {'connection': 'dummy_connection', - 'dataType': DataType.UNDEFINED, - 'direction': BindingDirection.OUT, - 'dummyField': 'dummy', - 'topicEndpointUri': 'dummy_topic_endpoint_uri', - 'topicKeySetting': 'dummy_topic_key_setting', - 'name': 'res', - 'type': EVENT_GRID}) + {'dataType': DataType.UNDEFINED, + 'direction': BindingDirection.OUT, + 'dummyField': 'dummy', + 'topicEndpointUri': 'dummy_topic_endpoint_uri', + 'topicKeySetting': 'dummy_topic_key_setting', + 'name': 'res', + 'type': EVENT_GRID}) + + def test_event_grid_output_valid_creation_with_connection(self): + output = EventGridOutput(name="res", + connection="dummy_connection", + data_type=DataType.UNDEFINED, + dummy_field="dummy") + + self.assertEqual(output.connection, "dummy_connection") + self.assertIsNone(output.topic_endpoint_uri) + self.assertIsNone(output.topic_key_setting) + + def test_event_grid_output_invalid_creation_with_both(self): + with self.assertRaises(ValueError) as context: + EventGridOutput(name="res", + connection="dummy_connection", + topic_endpoint_uri="dummy_topic_endpoint_uri", + topic_key_setting="dummy_topic_key_setting") + + self.assertTrue("Specify either the 'Connection' property or both " + "'TopicKeySetting' and 'TopicEndpointUri' properties, " + "but not both." in str(context.exception)) + + def test_event_grid_output_invalid_creation_with_none(self): + with self.assertRaises(ValueError) as context: + EventGridOutput(name="res", + data_type=DataType.UNDEFINED, + dummy_field="dummy") + + self.assertTrue("Specify either the 'Connection' property or both " + "'TopicKeySetting' and 'TopicEndpointUri' properties," + " but not both." in str(context.exception)) From b1d5418fa1efb09d6cc7017b81ffcc0e18b07c1e Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Tue, 2 Jul 2024 08:48:15 -0500 Subject: [PATCH 19/34] changed builds to use linux image (#231) Co-authored-by: Victoria Hall --- eng/templates/official/jobs/build-artifacts.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/eng/templates/official/jobs/build-artifacts.yml b/eng/templates/official/jobs/build-artifacts.yml index e2e6e01..bb2171e 100644 --- a/eng/templates/official/jobs/build-artifacts.yml +++ b/eng/templates/official/jobs/build-artifacts.yml @@ -2,6 +2,11 @@ jobs: - job: "Build" displayName: 'Build Python SDK' + pool: + name: 1es-pool-azfunc + image: 1es-ubuntu-22.04 + os: linux + templateContext: outputParentDirectory: $(Build.ArtifactStagingDirectory) outputs: From b37a3dd8715ab7c51351d34821e2b640e0fa7d4b Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Tue, 2 Jul 2024 09:09:03 -0500 Subject: [PATCH 20/34] removed codeql (#228) Co-authored-by: gavin-aguiar <80794152+gavin-aguiar@users.noreply.github.com> --- .github/workflows/codeql.yml | 89 ------------------------------------ 1 file changed, 89 deletions(-) delete mode 100644 .github/workflows/codeql.yml diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml deleted file mode 100644 index 8d21016..0000000 --- a/.github/workflows/codeql.yml +++ /dev/null @@ -1,89 +0,0 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL" - -on: - push: - branches: [ "dev"] - pull_request: - branches: [ "dev"] - schedule: - - cron: '28 2 * * 0' - -jobs: - analyze: - name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} - permissions: - # required for all workflows - security-events: write - - # only required for workflows in private repositories - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - include: - - language: python - build-mode: none - # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹī¸ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - if: matrix.build-mode == 'manual' - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" From 7904799b83595a4c27ba9d332e24a88e155f0897 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Wed, 3 Jul 2024 13:32:37 -0500 Subject: [PATCH 21/34] code clean up (#232) Co-authored-by: Victoria Hall --- .ci/build.sh | 5 ---- .ci/run_tests.sh | 7 ----- .github/workflows/gh-tests-ci.yml | 38 ------------------------- .github/workflows/pr_title_enforcer.yml | 20 +++++++++++++ eng/ci/public-build.yml | 2 ++ eng/templates/build.yml | 5 ++++ 6 files changed, 27 insertions(+), 50 deletions(-) delete mode 100644 .ci/build.sh delete mode 100644 .ci/run_tests.sh delete mode 100644 .github/workflows/gh-tests-ci.yml create mode 100644 .github/workflows/pr_title_enforcer.yml diff --git a/.ci/build.sh b/.ci/build.sh deleted file mode 100644 index e938206..0000000 --- a/.ci/build.sh +++ /dev/null @@ -1,5 +0,0 @@ -#!/bin/bash - -set -e -x - -python -m pip install -U -e .[dev] \ No newline at end of file diff --git a/.ci/run_tests.sh b/.ci/run_tests.sh deleted file mode 100644 index 98afcc0..0000000 --- a/.ci/run_tests.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/bin/bash - -set -e -x - -coverage run --branch -m pytest tests -coverage xml -coverage erase \ No newline at end of file diff --git a/.github/workflows/gh-tests-ci.yml b/.github/workflows/gh-tests-ci.yml deleted file mode 100644 index d235010..0000000 --- a/.github/workflows/gh-tests-ci.yml +++ /dev/null @@ -1,38 +0,0 @@ -name: Unittest pipeline - -on: - workflow_dispatch: - push: - pull_request: - branches: [ dev ] - schedule: - # Monday to Thursday 1 AM PDT build - - cron: "0 8 * * 1,2,3,4" - -jobs: - build: - runs-on: ubuntu-latest - strategy: - matrix: - python_version: [3.7, 3.8, 3.9, "3.10", "3.11"] - steps: - - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python_version }} - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python_version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - python -m pip install -U -e .[dev] - - name: Test with pytest - run: | - python -m pytest --cache-clear --cov=./azure --cov-report=xml --cov-branch tests - - name: Codecov - if: ${{ matrix.python-version }} == 3.9 - uses: codecov/codecov-action@v2 - with: - file: ./coverage.xml - flags: unittests - name: codecov - fail_ci_if_error: false diff --git a/.github/workflows/pr_title_enforcer.yml b/.github/workflows/pr_title_enforcer.yml new file mode 100644 index 0000000..00d21d3 --- /dev/null +++ b/.github/workflows/pr_title_enforcer.yml @@ -0,0 +1,20 @@ +name: "PR Title Enforcer" + +on: + pull_request: + types: + - opened + - edited + - synchronize + +permissions: + pull-requests: read + +jobs: + main: + name: Validate PR title + runs-on: ubuntu-latest + steps: + - uses: amannn/action-semantic-pull-request@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/eng/ci/public-build.yml b/eng/ci/public-build.yml index d3caca8..074d0eb 100644 --- a/eng/ci/public-build.yml +++ b/eng/ci/public-build.yml @@ -36,6 +36,8 @@ extends: compiled: enabled: true # still only runs for default branch runSourceLanguagesInSourceAnalysis: true + settings: + skipBuildTagsForGitHubPullRequests: ${{ variables['System.PullRequest.IsFork'] }} stages: - stage: Build diff --git a/eng/templates/build.yml b/eng/templates/build.yml index faf0148..44603de 100644 --- a/eng/templates/build.yml +++ b/eng/templates/build.yml @@ -2,6 +2,11 @@ jobs: - job: "Build" displayName: 'Build Python SDK' + pool: + name: 1es-pool-azfunc + image: 1es-ubuntu-22.04 + os: linux + steps: - task: UsePythonVersion@0 inputs: From ff2e803e5e93cad115b1d01aaf469aef33966c35 Mon Sep 17 00:00:00 2001 From: wangbill <12449837+YunchuWang@users.noreply.github.com> Date: Wed, 17 Jul 2024 09:56:42 -0700 Subject: [PATCH 22/34] fix asgi/wsgifunctionapps to extend bindingapi/settingapi (#209) Co-authored-by: gavin-aguiar <80794152+gavin-aguiar@users.noreply.github.com> Co-authored-by: hallvictoria <59299039+hallvictoria@users.noreply.github.com> --- azure/functions/decorators/function_app.py | 8 +++++++- tests/decorators/test_function_app.py | 20 ++++++++++++++++---- tests/test_http.py | 5 +++-- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index a716087..f2af63e 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -3313,7 +3313,13 @@ class Blueprint(TriggerApi, BindingApi, SettingsApi): pass -class ExternalHttpFunctionApp(FunctionRegister, TriggerApi, ABC): +class ExternalHttpFunctionApp( + FunctionRegister, + TriggerApi, + SettingsApi, + BindingApi, + ABC +): """Interface to extend for building third party http function apps.""" @abc.abstractmethod diff --git a/tests/decorators/test_function_app.py b/tests/decorators/test_function_app.py index 2f68726..c85b5df 100644 --- a/tests/decorators/test_function_app.py +++ b/tests/decorators/test_function_app.py @@ -1,5 +1,6 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +from abc import ABC import inspect import json import unittest @@ -11,10 +12,12 @@ from azure.functions.decorators.constants import HTTP_OUTPUT, HTTP_TRIGGER, \ TIMER_TRIGGER from azure.functions.decorators.core import DataType, AuthLevel, \ BindingDirection, SCRIPT_FILE_NAME -from azure.functions.decorators.function_app import BindingApi, \ - FunctionBuilder, FunctionApp, Function, Blueprint, DecoratorApi, \ - AsgiFunctionApp, WsgiFunctionApp, HttpFunctionsAuthLevelMixin, \ - FunctionRegister, TriggerApi, ExternalHttpFunctionApp +from azure.functions.decorators.function_app import ( + BindingApi, FunctionBuilder, FunctionApp, Function, Blueprint, + DecoratorApi, AsgiFunctionApp, SettingsApi, WsgiFunctionApp, + HttpFunctionsAuthLevelMixin, FunctionRegister, TriggerApi, + ExternalHttpFunctionApp +) from azure.functions.decorators.http import HttpTrigger, HttpOutput, \ HttpMethod from azure.functions.decorators.retry_policy import RetryPolicy @@ -322,6 +325,15 @@ class TestFunctionApp(unittest.TestCase): self.assertIsInstance(add_http_app_mock.call_args[0][0], WsgiMiddleware) + def test_extends_required_classes(self): + self.assertTrue(issubclass(ExternalHttpFunctionApp, FunctionRegister)) + self.assertTrue(issubclass(ExternalHttpFunctionApp, TriggerApi)) + self.assertTrue(issubclass(ExternalHttpFunctionApp, SettingsApi)) + self.assertTrue(issubclass(ExternalHttpFunctionApp, BindingApi)) + self.assertTrue(issubclass(ExternalHttpFunctionApp, ABC)) + self.assertTrue(issubclass(AsgiFunctionApp, ExternalHttpFunctionApp)) + self.assertTrue(issubclass(WsgiFunctionApp, ExternalHttpFunctionApp)) + def test_add_asgi_app(self): self._test_http_external_app(AsgiFunctionApp(app=object()), True) diff --git a/tests/test_http.py b/tests/test_http.py index f6dfab0..1efa82b 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -181,9 +181,10 @@ class TestHTTP(unittest.TestCase): def test_http_response_accepts_http_enums(self): response = func.HttpResponse(status_code=404) - self.assertEquals(response.status_code, 404) + self.assertEqual(response.status_code, 404) + response = func.HttpResponse(status_code=HTTPStatus.ACCEPTED) - self.assertEquals(response.status_code, 202) + self.assertEqual(response.status_code, HTTPStatus.ACCEPTED.value) def test_http_request_converter_decode(self): data = { From de37877f6b953bb2f71471f0ffd6e02efe9585f2 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Wed, 17 Jul 2024 12:25:46 -0500 Subject: [PATCH 23/34] fix: small memory leak (#223) * changing default {} to None * mypy fixes --------- Co-authored-by: Victoria Hall Co-authored-by: gavin-aguiar <80794152+gavin-aguiar@users.noreply.github.com> --- azure/functions/decorators/function_app.py | 16 ++++++++--- .../functions/extension/app_extension_base.py | 28 ++++++++++--------- .../extension/func_extension_base.py | 28 ++++++++++--------- azure/functions/timer.py | 9 +++--- tests/test_timer.py | 12 ++++++++ 5 files changed, 59 insertions(+), 34 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index f2af63e..401b89b 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -333,7 +333,7 @@ class DecoratorApi(ABC): return self._app_script_file def function_name(self, name: str, - setting_extra_fields: Dict[str, Any] = {}, + setting_extra_fields: Optional[Dict[str, Any]] = None, ) -> Callable[..., Any]: """Optional: Sets name of the :class:`Function` object. If not set, it will default to the name of the method name. @@ -343,6 +343,8 @@ class DecoratorApi(ABC): additional setting fields :return: Decorator function. """ + if setting_extra_fields is None: + setting_extra_fields = {} @self._configure_function_builder def wrap(fb): @@ -437,8 +439,8 @@ class TriggerApi(DecoratorApi, ABC): methods: Optional[ Union[Iterable[str], Iterable[HttpMethod]]] = None, auth_level: Optional[Union[AuthLevel, str]] = None, - trigger_extra_fields: Dict[str, Any] = {}, - binding_extra_fields: Dict[str, Any] = {} + trigger_extra_fields: Optional[Dict[str, Any]] = None, + binding_extra_fields: Optional[Dict[str, Any]] = None ) -> Callable[..., Any]: """The route decorator adds :class:`HttpTrigger` and :class:`HttpOutput` binding to the :class:`FunctionBuilder` object @@ -469,6 +471,10 @@ class TriggerApi(DecoratorApi, ABC): json. For example, >>> data_type='STRING' # 'dataType': 'STRING' in binding json """ + if trigger_extra_fields is None: + trigger_extra_fields = {} + if binding_extra_fields is None: + binding_extra_fields = {} @self._configure_function_builder def wrap(fb): @@ -3197,7 +3203,7 @@ class SettingsApi(DecoratorApi, ABC): delay_interval: Optional[str] = None, minimum_interval: Optional[str] = None, maximum_interval: Optional[str] = None, - setting_extra_fields: Dict[str, Any] = {}, + setting_extra_fields: Optional[Dict[str, Any]] = None, ) -> Callable[..., Any]: """The retry decorator adds :class:`RetryPolicy` to the function settings object for building :class:`Function` object used in worker @@ -3219,6 +3225,8 @@ class SettingsApi(DecoratorApi, ABC): additional setting fields. :return: Decorator function. """ + if setting_extra_fields is None: + setting_extra_fields = {} @self._configure_function_builder def wrap(fb): diff --git a/azure/functions/extension/app_extension_base.py b/azure/functions/extension/app_extension_base.py index 22675f8..02f6709 100644 --- a/azure/functions/extension/app_extension_base.py +++ b/azure/functions/extension/app_extension_base.py @@ -62,12 +62,13 @@ class AppExtensionBase(metaclass=ExtensionMeta): # DO NOT decorate this with @abc.abstractstatismethod # since implementation by subclass is not mandatory @classmethod - def pre_invocation_app_level(cls, - logger: Logger, - context: Context, - func_args: typing.Dict[str, object] = {}, - *args, - **kwargs) -> None: + def pre_invocation_app_level( + cls, + logger: Logger, + context: Context, + func_args: typing.Optional[typing.Dict[str, object]] = None, + *args, + **kwargs) -> None: """This must be implemented as a @staticmethod. It will be called right before a customer's function is being executed. @@ -90,13 +91,14 @@ class AppExtensionBase(metaclass=ExtensionMeta): # DO NOT decorate this with @abc.abstractstatismethod # since implementation by subclass is not mandatory @classmethod - def post_invocation_app_level(cls, - logger: Logger, - context: Context, - func_args: typing.Dict[str, object] = {}, - func_ret: typing.Optional[object] = None, - *args, - **kwargs) -> None: + def post_invocation_app_level( + cls, + logger: Logger, + context: Context, + func_args: typing.Optional[typing.Dict[str, object]] = None, + func_ret: typing.Optional[object] = None, + *args, + **kwargs) -> None: """This must be implemented as a @staticmethod. It will be called right after a customer's function is being executed. diff --git a/azure/functions/extension/func_extension_base.py b/azure/functions/extension/func_extension_base.py index 46a38b5..5a3b6dc 100644 --- a/azure/functions/extension/func_extension_base.py +++ b/azure/functions/extension/func_extension_base.py @@ -86,12 +86,13 @@ class FuncExtensionBase(metaclass=ExtensionMeta): # DO NOT decorate this with @abc.abstractmethod # since implementation by subclass is not mandatory - def pre_invocation(self, - logger: Logger, - context: Context, - func_args: typing.Dict[str, object] = {}, - *args, - **kwargs) -> None: + def pre_invocation( + self, + logger: Logger, + context: Context, + func_args: typing.Optional[typing.Dict[str, object]] = None, + *args, + **kwargs) -> None: """This hook will be called right before customer's function is being executed. @@ -113,13 +114,14 @@ class FuncExtensionBase(metaclass=ExtensionMeta): # DO NOT decorate this with @abc.abstractmethod # since implementation by subclass is not mandatory - def post_invocation(self, - logger: Logger, - context: Context, - func_args: typing.Dict[str, object] = {}, - func_ret: typing.Optional[object] = None, - *args, - **kwargs) -> None: + def post_invocation( + self, + logger: Logger, + context: Context, + func_args: typing.Optional[typing.Dict[str, object]] = None, + func_ret: typing.Optional[object] = None, + *args, + **kwargs) -> None: """This hook will be called right after a customer's function is executed. diff --git a/azure/functions/timer.py b/azure/functions/timer.py index 56bdc8e..92ef71c 100644 --- a/azure/functions/timer.py +++ b/azure/functions/timer.py @@ -10,11 +10,12 @@ from . import meta class TimerRequest(azf_abc.TimerRequest): - def __init__(self, *, past_due: bool = False, schedule_status: dict = {}, - schedule: dict = {}) -> None: + def __init__(self, *, past_due: bool = False, + schedule_status: typing.Optional[dict] = None, + schedule: typing.Optional[dict] = None) -> None: self.__past_due = past_due - self.__schedule_status = schedule_status - self.__schedule = schedule + self.__schedule_status = schedule_status if schedule_status else {} + self.__schedule = schedule if schedule else {} @property def past_due(self) -> bool: diff --git a/tests/test_timer.py b/tests/test_timer.py index 4b21022..5198482 100644 --- a/tests/test_timer.py +++ b/tests/test_timer.py @@ -44,6 +44,18 @@ class TestTimer(unittest.TestCase): self.assertEqual(schedule_status, test_timer.schedule_status) self.assertEqual(schedule, test_timer.schedule) + def test_timer_initialize_empty_dicts(self): + # given + past_due = False + + # when + test_timer = timer.TimerRequest() + + # then + self.assertEqual(past_due, test_timer.past_due) + self.assertEqual({}, test_timer.schedule_status) + self.assertEqual({}, test_timer.schedule) + def test_timer_no_implementation_exception(self): # given datum: Datum = Datum(value="test", type='string') From b2e31ec43609eb6f69e8c26b1532fc28fe4264b6 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Mon, 22 Jul 2024 09:44:21 -0500 Subject: [PATCH 24/34] fix: enforcing unique function names (#216) * unique function names * added blueprint tests * changing test function names * lint * lint * fixed test_decorator tests * fixed function_app tests * configurable fx name for wsgi / asgi * lint * last test * dapr test fx names * feedback * missed refs * incorrect check * missed reg * type hint --------- Co-authored-by: Victoria Hall --- azure/functions/decorators/function_app.py | 46 ++- tests/decorators/test_dapr.py | 18 +- tests/decorators/test_decorators.py | 176 +++++---- tests/decorators/test_function_app.py | 432 +++++++++++++++++++-- 4 files changed, 528 insertions(+), 144 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 401b89b..14af303 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -204,6 +204,8 @@ class Function(object): class FunctionBuilder(object): + function_bindings: dict = {} + def __init__(self, func, function_script_file): self._function = Function(func, function_script_file) @@ -232,6 +234,12 @@ class FunctionBuilder(object): """ Validates the function information before building the function. + Functions with the same function name are not supported and should + fail indexing. If a function name is not defined, the default is the + method name. This also means that two functions with the same + method name will also fail indexing. + https://github.com/Azure/azure-functions-python-worker/issues/1489 + :param auth_level: Http auth level that will be set if http trigger function auth level is None. """ @@ -262,6 +270,16 @@ class FunctionBuilder(object): parse_singular_param_to_enum(auth_level, AuthLevel)) self._function._is_http_function = True + # This dict contains the function name and its bindings for all + # functions in an app. If a previous function has the same name, + # indexing will fail here. + if self.function_bindings.get(function_name, None): + raise ValueError( + f"Function {function_name} does not have a unique" + f" function name. Please change @app.function_name() or" + f" the function method name to be unique.") + self.function_bindings[function_name] = bindings + def build(self, auth_level: Optional[AuthLevel] = None) -> Function: """ Validates and builds the function object. @@ -3333,11 +3351,13 @@ class ExternalHttpFunctionApp( @abc.abstractmethod def _add_http_app(self, http_middleware: Union[ - AsgiMiddleware, WsgiMiddleware]) -> None: + AsgiMiddleware, WsgiMiddleware], + function_name: str = 'http_app_func') -> None: """Add a Wsgi or Asgi app integrated http function. :param http_middleware: :class:`WsgiMiddleware` or class:`AsgiMiddleware` instance. + :param function_name: name for the function :return: None """ @@ -3346,17 +3366,18 @@ class ExternalHttpFunctionApp( class AsgiFunctionApp(ExternalHttpFunctionApp): def __init__(self, app, - http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION): + http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION, + function_name: str = 'http_app_func'): """Constructor of :class:`AsgiFunctionApp` object. :param app: asgi app object. :param http_auth_level: Determines what keys, if any, need to be - present - on the request in order to invoke the function. + present on the request in order to invoke the function. + :param function_name: function name """ super().__init__(auth_level=http_auth_level) self.middleware = AsgiMiddleware(app) - self._add_http_app(self.middleware) + self._add_http_app(self.middleware, function_name) self.startup_task_done = False def __del__(self): @@ -3365,7 +3386,8 @@ class AsgiFunctionApp(ExternalHttpFunctionApp): def _add_http_app(self, http_middleware: Union[ - AsgiMiddleware, WsgiMiddleware]) -> None: + AsgiMiddleware, WsgiMiddleware], + function_name: str = 'http_app_func') -> None: """Add an Asgi app integrated http function. :param http_middleware: :class:`WsgiMiddleware` @@ -3379,6 +3401,7 @@ class AsgiFunctionApp(ExternalHttpFunctionApp): asgi_middleware: AsgiMiddleware = http_middleware + @self.function_name(name=function_name) @self.http_type(http_type='asgi') @self.route(methods=(method for method in HttpMethod), auth_level=self.auth_level, @@ -3395,21 +3418,25 @@ class AsgiFunctionApp(ExternalHttpFunctionApp): class WsgiFunctionApp(ExternalHttpFunctionApp): def __init__(self, app, - http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION): + http_auth_level: Union[AuthLevel, str] = AuthLevel.FUNCTION, + function_name: str = 'http_app_func'): """Constructor of :class:`WsgiFunctionApp` object. :param app: wsgi app object. + :param function_name: function name """ super().__init__(auth_level=http_auth_level) - self._add_http_app(WsgiMiddleware(app)) + self._add_http_app(WsgiMiddleware(app), function_name) def _add_http_app(self, http_middleware: Union[ - AsgiMiddleware, WsgiMiddleware]) -> None: + AsgiMiddleware, WsgiMiddleware], + function_name: str = 'http_app_func') -> None: """Add a Wsgi app integrated http function. :param http_middleware: :class:`WsgiMiddleware` or class:`AsgiMiddleware` instance. + :param function_name: name for the function :return: None """ @@ -3419,6 +3446,7 @@ class WsgiFunctionApp(ExternalHttpFunctionApp): wsgi_middleware: WsgiMiddleware = http_middleware + @self.function_name(function_name) @self.http_type(http_type='wsgi') @self.route(methods=(method for method in HttpMethod), auth_level=self.auth_level, diff --git a/tests/decorators/test_dapr.py b/tests/decorators/test_dapr.py index 522f1b5..c87fa39 100644 --- a/tests/decorators/test_dapr.py +++ b/tests/decorators/test_dapr.py @@ -24,7 +24,7 @@ class TestDapr(unittest.TestCase): @app.dapr_service_invocation_trigger(arg_name="req", method_name="dummy_method_name") - def dummy(): + def test_dapr_service_invocation_trigger_default_args(): pass func = self._get_user_function(app) @@ -50,7 +50,7 @@ class TestDapr(unittest.TestCase): @app.dapr_binding_trigger(arg_name="req", binding_name="dummy_binding_name") - def dummy(): + def test_dapr_binding_trigger_default_args(): pass func = self._get_user_function(app) @@ -73,7 +73,7 @@ class TestDapr(unittest.TestCase): pub_sub_name="dummy_pub_sub_name", topic="dummy_topic", route="/dummy_route") - def dummy(): + def test_dapr_topic_trigger_default_args(): pass func = self._get_user_function(app) @@ -99,7 +99,7 @@ class TestDapr(unittest.TestCase): @app.dapr_state_input(arg_name="in", state_store="dummy_state_store", key="dummy_key") - def dummy(): + def test_dapr_state_input_binding(): pass func = self._get_user_function(app) @@ -125,7 +125,7 @@ class TestDapr(unittest.TestCase): secret_store_name="dummy_secret_store_name", key="dummy_key", metadata="dummy_metadata") - def dummy(): + def test_dapr_secret_input_binding(): pass func = self._get_user_function(app) @@ -151,7 +151,7 @@ class TestDapr(unittest.TestCase): @app.dapr_state_output(arg_name="out", state_store="dummy_state_store", key="dummy_key") - def dummy(): + def test_dapr_state_output_binding(): pass func = self._get_user_function(app) @@ -177,7 +177,7 @@ class TestDapr(unittest.TestCase): app_id="dummy_app_id", method_name="dummy_method_name", http_verb="dummy_http_verb") - def dummy(): + def test_dapr_invoke_output_binding(): pass func = self._get_user_function(app) @@ -203,7 +203,7 @@ class TestDapr(unittest.TestCase): @app.dapr_publish_output(arg_name="out", pub_sub_name="dummy_pub_sub_name", topic="dummy_topic") - def dummy(): + def test_dapr_publish_output_binding(): pass func = self._get_user_function(app) @@ -228,7 +228,7 @@ class TestDapr(unittest.TestCase): @app.dapr_binding_output(arg_name="out", binding_name="dummy_binding_name", operation="dummy_operation") - def dummy(): + def test_dapr_binding_output_binding(): pass func = self._get_user_function(app) diff --git a/tests/decorators/test_decorators.py b/tests/decorators/test_decorators.py index 1bd16de..82973ba 100644 --- a/tests/decorators/test_decorators.py +++ b/tests/decorators/test_decorators.py @@ -27,11 +27,11 @@ class TestFunctionsApp(unittest.TestCase): def test_route_is_function_name(self): app = self.func_app - test_func_name = "dummy_function" + test_func_name = "test_route_is_function_name" @app.function_name(test_func_name) @app.route() - def dummy_func(): + def test_route_is_function_name(): pass func = self._get_user_function(app) @@ -44,26 +44,28 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.route() - def dummy_func(): + def test_route_is_python_function_name(): pass func = self._get_user_function(app) - self.assertEqual(func.get_function_name(), "dummy_func") + self.assertEqual(func.get_function_name(), + "test_route_is_python_function_name") self.assertTrue(isinstance(func.get_trigger(), HttpTrigger)) - self.assertTrue(func.get_trigger().route, "dummy_func") + self.assertTrue(func.get_trigger().route, + "test_route_is_python_function_name") def test_route_is_custom(self): app = self.func_app - @app.function_name("dummy_function") + @app.function_name("test_route_is_custom") @app.route("dummy") def dummy_func(): pass func = self._get_user_function(app) - self.assertEqual("dummy_function", func.get_function_name()) + self.assertEqual("test_route_is_custom", func.get_function_name()) self.assertTrue(isinstance(func.get_trigger(), HttpTrigger)) self.assertTrue(func.get_trigger().route, "dummy") @@ -71,11 +73,12 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.schedule(arg_name="req", schedule="dummy_schedule") - def dummy_func(): + def test_schedule_trigger_default_args(): pass func = self._get_user_function(app) - self.assertEqual(func.get_function_name(), "dummy_func") + self.assertEqual(func.get_function_name(), + "test_schedule_trigger_default_args") assert_json(self, func, { "scriptFile": "function_app.py", "bindings": [ @@ -94,7 +97,7 @@ class TestFunctionsApp(unittest.TestCase): @app.schedule(arg_name="req", schedule="dummy_schedule", run_on_startup=False, use_monitor=False, data_type=DataType.STRING, dummy_field='dummy') - def dummy(): + def test_schedule_trigger_full_args(): pass func = self._get_user_function(app) @@ -118,11 +121,12 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.timer_trigger(arg_name="req", schedule="dummy_schedule") - def dummy_func(): + def test_timer_trigger_default_args(): pass func = self._get_user_function(app) - self.assertEqual(func.get_function_name(), "dummy_func") + self.assertEqual(func.get_function_name(), + "test_timer_trigger_default_args") assert_json(self, func, { "scriptFile": "function_app.py", "bindings": [ @@ -141,7 +145,7 @@ class TestFunctionsApp(unittest.TestCase): @app.timer_trigger(arg_name="req", schedule="dummy_schedule", run_on_startup=False, use_monitor=False, data_type=DataType.STRING, dummy_field='dummy') - def dummy(): + def test_timer_trigger_full_args(): pass func = self._get_user_function(app) @@ -165,7 +169,7 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.orchestration_trigger("context") - def dummy1(context): + def test_orchestration_trigger(context): pass func = self._get_user_function(app) @@ -184,7 +188,7 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.activity_trigger("arg") - def dummy2(arg): + def test_activity_trigger(arg): pass func = self._get_user_function(app) @@ -203,7 +207,7 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.entity_trigger("context") - def dummy3(context): + def test_entity_trigger(context): pass func = self._get_user_function(app) @@ -223,7 +227,7 @@ class TestFunctionsApp(unittest.TestCase): @app.generic_trigger(arg_name="req", type=HTTP_TRIGGER) @app.durable_client_input(client_name="client") - def dummy(client): + def test_durable_client(client): pass func = self._get_user_function(app) @@ -243,7 +247,7 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.route() - def dummy(): + def test_route_default_args(): pass func = self._get_user_function(app) @@ -255,7 +259,7 @@ class TestFunctionsApp(unittest.TestCase): "direction": BindingDirection.IN, "type": HTTP_TRIGGER, "name": "req", - "route": "dummy" + "route": "test_route_default_args" }, { "direction": BindingDirection.OUT, @@ -273,7 +277,7 @@ class TestFunctionsApp(unittest.TestCase): auth_level=AuthLevel.FUNCTION, route='dummy_route', trigger_extra_fields={"dummy_field": "dummy"}, binding_extra_fields={"dummy_field": "dummy"}) - def dummy(): + def test_route_with_all_args(): pass func = self._get_user_function(app) @@ -304,11 +308,12 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.warm_up_trigger(arg_name="req") - def dummy_func(): + def test_warmup_trigger_default_args(): pass func = self._get_user_function(app) - self.assertEqual(func.get_function_name(), "dummy_func") + self.assertEqual(func.get_function_name(), + "test_warmup_trigger_default_args") assert_json(self, func, { "scriptFile": "function_app.py", "bindings": [ @@ -325,7 +330,7 @@ class TestFunctionsApp(unittest.TestCase): @app.warm_up_trigger(arg_name="req", data_type=DataType.STRING, dummy_field='dummy') - def dummy(): + def test_warmup_trigger_full_args(): pass func = self._get_user_function(app) @@ -349,7 +354,7 @@ class TestFunctionsApp(unittest.TestCase): connection="dummy_conn") @app.queue_output(arg_name="out", queue_name="dummy_out_queue", connection="dummy_out_conn") - def dummy(): + def test_queue_default_args(): pass func = self._get_user_function(app) @@ -376,7 +381,7 @@ class TestFunctionsApp(unittest.TestCase): @app.queue_trigger(arg_name="req", queue_name="dummy_queue", connection="dummy_conn") - def dummy(): + def test_queue_trigger(): pass func = self._get_user_function(app) @@ -399,7 +404,7 @@ class TestFunctionsApp(unittest.TestCase): connection="dummy_conn") @app.queue_output(arg_name="out", queue_name="dummy_out_queue", connection="dummy_out_conn") - def dummy(): + def test_queue_output_binding(): pass func = self._get_user_function(app) @@ -424,7 +429,7 @@ class TestFunctionsApp(unittest.TestCase): @app.queue_output(arg_name="out", queue_name="dummy_out_queue", connection="dummy_out_conn", data_type=DataType.STRING, dummy_field="dummy") - def dummy(): + def test_queue_full_args(): pass func = self._get_user_function(app) @@ -459,7 +464,7 @@ class TestFunctionsApp(unittest.TestCase): @app.service_bus_queue_output(arg_name='res', connection='dummy_out_conn', queue_name='dummy_out_queue') - def dummy(): + def test_service_bus_queue_default_args(): pass func = self._get_user_function(app) @@ -489,7 +494,7 @@ class TestFunctionsApp(unittest.TestCase): @app.service_bus_queue_trigger(arg_name="req", connection="dummy_conn", queue_name="dummy_queue") - def dummy(): + def test_service_bus_queue_trigger(): pass func = self._get_user_function(app) @@ -514,7 +519,7 @@ class TestFunctionsApp(unittest.TestCase): @app.service_bus_queue_output(arg_name='res', connection='dummy_out_conn', queue_name='dummy_out_queue') - def dummy(): + def test_service_bus_queue_output_binding(): pass func = self._get_user_function(app) @@ -547,7 +552,7 @@ class TestFunctionsApp(unittest.TestCase): data_type=DataType.STREAM, access_rights=AccessRights.MANAGE, dummy_field="dummy") - def dummy(): + def test_service_bus_queue_full_args(): pass func = self._get_user_function(app) @@ -589,7 +594,7 @@ class TestFunctionsApp(unittest.TestCase): @app.service_bus_topic_output(arg_name='res', connection='dummy_conn', topic_name='dummy_topic', subscription_name='dummy_sub') - def dummy(): + def test_service_bus_topic_default_args(): pass func = self._get_user_function(app) @@ -622,7 +627,7 @@ class TestFunctionsApp(unittest.TestCase): connection='dummy_conn', topic_name='dummy_topic', subscription_name='dummy_sub') - def dummy(): + def test_service_bus_topic_trigger(): pass func = self._get_user_function(app) @@ -649,7 +654,7 @@ class TestFunctionsApp(unittest.TestCase): @app.service_bus_topic_output(arg_name='res', connection='dummy_conn', topic_name='dummy_topic', subscription_name='dummy_sub') - def dummy(): + def test_service_bus_topic_output_binding(): pass func = self._get_user_function(app) @@ -684,7 +689,7 @@ class TestFunctionsApp(unittest.TestCase): data_type=DataType.STRING, access_rights=AccessRights.LISTEN, dummy_field="dummy") - def dummy(): + def test_service_bus_topic_full_args(): pass func = self._get_user_function(app) @@ -727,7 +732,7 @@ class TestFunctionsApp(unittest.TestCase): @app.event_hub_output(arg_name="res", event_hub_name="dummy_event_hub", connection="dummy_connection") - def dummy(): + def test_event_hub_default_args(): pass func = self._get_user_function(app) @@ -757,7 +762,7 @@ class TestFunctionsApp(unittest.TestCase): @app.event_hub_message_trigger(arg_name="req", connection="dummy_connection", event_hub_name="dummy_event_hub") - def dummy(): + def test_event_hub_trigger(): pass func = self._get_user_function(app) @@ -782,7 +787,7 @@ class TestFunctionsApp(unittest.TestCase): @app.event_hub_output(arg_name="res", event_hub_name="dummy_event_hub", connection="dummy_connection") - def dummy(): + def test_event_hub_output_binding(): pass func = self._get_user_function(app) @@ -813,7 +818,7 @@ class TestFunctionsApp(unittest.TestCase): connection="dummy_connection", data_type=DataType.UNDEFINED, dummy_field="dummy") - def dummy(): + def test_event_hub_full_args(): pass func = self._get_user_function(app) @@ -889,7 +894,7 @@ class TestFunctionsApp(unittest.TestCase): preferred_locations="dummy_location", data_type=DataType.STRING, dummy_field="dummy") - def dummy(): + def test_cosmosdb_v3_full_args(): pass func = self._get_user_function(app) @@ -1006,7 +1011,7 @@ class TestFunctionsApp(unittest.TestCase): preferred_locations="dummy_location", data_type=DataType.STRING, dummy_field="dummy") - def dummy(): + def test_cosmosdb_full_args(): pass func = self._get_user_function(app) @@ -1090,7 +1095,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_out_db", collection_name="dummy_out_collection", connection_string_setting="dummy_str") - def dummy(): + def test_cosmosdb_v3_default_args(): pass func = self._get_user_function(app) @@ -1140,7 +1145,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_out_db", container_name="dummy_out_container", connection="dummy_str") - def dummy(): + def test_cosmosdb_default_args(): pass func = self._get_user_function(app) @@ -1183,7 +1188,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_db", collection_name="dummy_collection", connection_string_setting="dummy_str") - def dummy(): + def test_cosmosdb_v3_trigger(): pass func = self._get_user_function(app) @@ -1207,7 +1212,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_db", container_name="dummy_container", connection="dummy_str") - def dummy(): + def test_cosmosdb_trigger(): pass func = self._get_user_function(app) @@ -1231,7 +1236,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_db", container_name="dummy_container", connection="dummy_str") - def dummy(): + def test_not_http_function(): pass funcs = app.get_functions() @@ -1250,7 +1255,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_in_db", collection_name="dummy_in_collection", connection_string_setting="dummy_str") - def dummy(): + def test_cosmosdb_v3_input_binding(): pass func = self._get_user_function(app) @@ -1279,7 +1284,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_in_db", container_name="dummy_in_container", connection="dummy_str") - def dummy(): + def test_cosmosdb_input_binding(): pass func = self._get_user_function(app) @@ -1308,7 +1313,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_out_db", collection_name="dummy_out_collection", connection_string_setting="dummy_str") - def dummy(): + def test_cosmosdb_v3_output_binding(): pass func = self._get_user_function(app) @@ -1337,7 +1342,7 @@ class TestFunctionsApp(unittest.TestCase): database_name="dummy_out_db", container_name="dummy_out_container", connection="dummy_str") - def dummy(): + def test_cosmosdb_output_binding(): pass func = self._get_user_function(app) @@ -1363,7 +1368,7 @@ class TestFunctionsApp(unittest.TestCase): @app.schedule(arg_name="req1", schedule="dummy_schedule") @app.schedule(arg_name="req2", schedule="dummy_schedule") - def dummy(): + def test_multiple_triggers(): pass self.assertEqual(err.exception.args[0], "A trigger was already registered to this " @@ -1378,15 +1383,15 @@ class TestFunctionsApp(unittest.TestCase): with self.assertRaises(ValueError) as err: @app.queue_output(arg_name="out", queue_name="dummy_out_queue", connection="dummy_out_conn") - def dummy(): + def test_no_trigger(): pass app.get_functions() self.assertEqual(err.exception.args[0], - "Function dummy does not have a trigger. A valid " - "function must have one and only one trigger " - "registered.") + "Function test_no_trigger does not have a trigger." + " A valid function must have one and only one" + " trigger registered.") def test_multiple_input_bindings(self): app = self.func_app @@ -1416,7 +1421,7 @@ class TestFunctionsApp(unittest.TestCase): arg_name="res", event_hub_name="dummy_event_hub", connection="dummy_connection") - def dummy(): + def test_multiple_input_bindings(): pass func = self._get_user_function(app) @@ -1543,7 +1548,7 @@ class TestFunctionsApp(unittest.TestCase): connection="dummy_conn") @app.blob_output(arg_name="out", path="dummy_out_path", connection="dummy_out_conn") - def dummy(): + def test_blob_default_args(): pass func = self._get_user_function(app) @@ -1578,7 +1583,7 @@ class TestFunctionsApp(unittest.TestCase): @app.blob_trigger(arg_name="req", path="dummy_path", data_type=DataType.STRING, connection="dummy_conn") - def dummy(): + def test_blob_trigger(): pass func = self._get_user_function(app) @@ -1606,7 +1611,7 @@ class TestFunctionsApp(unittest.TestCase): @app.blob_input(arg_name="file", path="dummy_in_path", connection="dummy_in_conn", data_type=DataType.STRING) - def dummy(): + def test_blob_input_binding(): pass func = self._get_user_function(app) @@ -1645,7 +1650,7 @@ class TestFunctionsApp(unittest.TestCase): @app.blob_output(arg_name="out", path="dummy_out_path", connection="dummy_out_conn", data_type=DataType.STRING) - def dummy(): + def test_blob_output_binding(): pass func = self._get_user_function(app) @@ -1681,7 +1686,7 @@ class TestFunctionsApp(unittest.TestCase): data_type=DataType.BINARY, connection="dummy_conn", path="dummy_path") - def dummy(): + def test_custom_trigger(): pass func = self._get_user_function(app) @@ -1710,7 +1715,7 @@ class TestFunctionsApp(unittest.TestCase): path="dummy_in_path", connection="dummy_in_conn", data_type=DataType.STRING) - def dummy(): + def test_custom_input_binding(): pass func = self._get_user_function(app) @@ -1748,7 +1753,7 @@ class TestFunctionsApp(unittest.TestCase): path="dummy_out_path", connection="dummy_out_conn", data_type=DataType.STRING) - def dummy(): + def test_custom_output_binding(): pass func = self._get_user_function(app) @@ -1777,7 +1782,7 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.generic_trigger(arg_name="req", type=HTTP_TRIGGER) - def dummy(): + def test_custom_http_trigger(): pass func = self._get_user_function(app) @@ -1791,7 +1796,7 @@ class TestFunctionsApp(unittest.TestCase): "direction": BindingDirection.IN, "type": HTTP_TRIGGER, "name": "req", - "route": "dummy", + "route": "test_custom_http_trigger", "authLevel": AuthLevel.FUNCTION }) @@ -1800,7 +1805,7 @@ class TestFunctionsApp(unittest.TestCase): @app.generic_trigger(arg_name="req", type=QUEUE_TRIGGER, direction=BindingDirection.INOUT) - def dummy(): + def test_custom_binding_with_excluded_params(): pass func = self._get_user_function(app) @@ -1822,7 +1827,7 @@ class TestFunctionsApp(unittest.TestCase): path="dummy_out_path", connection="dummy_out_conn", data_type=DataType.STRING) - def dummy(): + def test_mixed_custom_and_supported_binding(): pass func = self._get_user_function(app) @@ -1855,7 +1860,7 @@ class TestFunctionsApp(unittest.TestCase): arg_name="res", topic_endpoint_uri="dummy_topic_endpoint_uri", topic_key_setting="dummy_topic_key_setting") - def dummy(): + def test_event_grid_default_args(): pass func = self._get_user_function(app) @@ -1891,7 +1896,7 @@ class TestFunctionsApp(unittest.TestCase): data_type=DataType.UNDEFINED, dummy_field="dummy" ) - def dummy(): + def test_event_grid_full_args(): pass func = self._get_user_function(app) @@ -1922,7 +1927,7 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.event_grid_trigger(arg_name="req") - def dummy(): + def test_event_grid_trigger(): pass func = self._get_user_function(app) @@ -1944,7 +1949,7 @@ class TestFunctionsApp(unittest.TestCase): arg_name="res", topic_endpoint_uri="dummy_topic_endpoint_uri", topic_key_setting="dummy_topic_key_setting") - def dummy(): + def test_event_grid_output_binding(): pass func = self._get_user_function(app) @@ -1970,7 +1975,7 @@ class TestFunctionsApp(unittest.TestCase): connection="dummy_out_conn", row_key="dummy_key", partition_key="dummy_partition_key") - def dummy(): + def test_table_default_args(): pass func = self._get_user_function(app) @@ -1999,7 +2004,7 @@ class TestFunctionsApp(unittest.TestCase): "type": HTTP_TRIGGER, "name": "req", "authLevel": AuthLevel.FUNCTION, - "route": "dummy" + "route": "test_table_default_args" }, { "direction": BindingDirection.OUT, @@ -2027,7 +2032,7 @@ class TestFunctionsApp(unittest.TestCase): connection="dummy_out_conn", row_key="dummy_key", partition_key="dummy_partition_key") - def dummy(): + def test_table_with_all_args(): pass func = self._get_user_function(app) @@ -2085,7 +2090,7 @@ class TestFunctionsApp(unittest.TestCase): take=1, filter="dummy_filter", data_type=DataType.STRING) - def dummy(): + def test_table_input_binding(): pass func = self._get_user_function(app) @@ -2116,7 +2121,7 @@ class TestFunctionsApp(unittest.TestCase): row_key="dummy_key", partition_key="dummy_partition_key", data_type=DataType.STRING) - def dummy(): + def test_table_output_binding(): pass func = self._get_user_function(app) @@ -2148,7 +2153,7 @@ class TestFunctionsApp(unittest.TestCase): @app.sql_output(arg_name="out", command_text="dummy_table", connection_string_setting="dummy_setting") - def dummy(): + def test_sql_default_args(): pass func = self._get_user_function(app) @@ -2201,7 +2206,7 @@ class TestFunctionsApp(unittest.TestCase): connection_string_setting="dummy_setting", data_type=DataType.STRING, dummy_field="dummy") - def dummy(): + def test_sql_full_args(): pass func = self._get_user_function(app) @@ -2247,7 +2252,7 @@ class TestFunctionsApp(unittest.TestCase): @app.sql_trigger(arg_name="trigger", table_name="dummy_table", connection_string_setting="dummy_setting") - def dummy(): + def test_sql_trigger(): pass func = self._get_user_function(app) @@ -2272,7 +2277,7 @@ class TestFunctionsApp(unittest.TestCase): @app.sql_input(arg_name="in", command_text="dummy_query", connection_string_setting="dummy_setting") - def dummy(): + def test_sql_input_binding(): pass func = self._get_user_function(app) @@ -2298,7 +2303,7 @@ class TestFunctionsApp(unittest.TestCase): @app.sql_output(arg_name="out", command_text="dummy_table", connection_string_setting="dummy_setting") - def dummy(): + def test_sql_output_binding(): pass func = self._get_user_function(app) @@ -2332,7 +2337,7 @@ class TestFunctionsApp(unittest.TestCase): connection="dummy_out_conn", row_key="dummy_key", partition_key="dummy_partition_key") - def dummy(): + def test_function_app_full_bindings_metadata_key_order(): pass self._test_function_metadata_order(app) @@ -2341,7 +2346,7 @@ class TestFunctionsApp(unittest.TestCase): app = self.func_app @app.generic_trigger(arg_name="req", type=HTTP_TRIGGER) - def dummy(): + def test_function_app_generic_http_trigger_metadata_key_order(): pass self._test_function_metadata_order(app) @@ -2359,11 +2364,12 @@ class TestFunctionsApp(unittest.TestCase): @app.schedule(arg_name="req", schedule="dummy_schedule") @app.retry(strategy="fixed", max_retry_count="2", delay_interval="4") - def dummy_func(): + def test_function_app_retry_default_args(): pass func = self._get_user_function(app) - self.assertEqual(func.get_function_name(), "dummy_func") + self.assertEqual(func.get_function_name(), + "test_function_app_retry_default_args") self.assertEqual(func.get_setting("retry_policy").get_dict_repr(), { 'setting_name': 'retry_policy', 'strategy': 'fixed', diff --git a/tests/decorators/test_function_app.py b/tests/decorators/test_function_app.py index c85b5df..b2cd0d9 100644 --- a/tests/decorators/test_function_app.py +++ b/tests/decorators/test_function_app.py @@ -20,6 +20,7 @@ from azure.functions.decorators.function_app import ( ) from azure.functions.decorators.http import HttpTrigger, HttpOutput, \ HttpMethod +from azure.functions.decorators.timer import TimerTrigger from azure.functions.decorators.retry_policy import RetryPolicy from test_core import DummyTrigger from tests.utils.testutils import assert_json @@ -114,30 +115,42 @@ class TestFunction(unittest.TestCase): class TestFunctionBuilder(unittest.TestCase): - def setUp(self): - def dummy(): - return "dummy" - - self.dummy = dummy - self.fb = FunctionBuilder(self.dummy, "dummy.py") def test_function_builder_creation(self): + def test_function_builder_creation(): + return "dummy" + + self.dummy = test_function_builder_creation + self.fb = FunctionBuilder(self.dummy, "dummy.py") + self.assertTrue(callable(self.fb)) func = getattr(self.fb, "_function") self.assertEqual(self.fb._function.function_script_file, "dummy.py") self.assertEqual(func.get_user_function(), self.dummy) def test_validate_function_missing_trigger(self): + def test_validate_function_missing_trigger(): + return "dummy" + + self.dummy = test_validate_function_missing_trigger + self.fb = FunctionBuilder(self.dummy, "dummy.py") + with self.assertRaises(ValueError) as err: # self.fb.configure_function_name('dummy').build() self.fb.build() self.assertEqual(err.exception.args[0], - "Function dummy does not have a trigger. A valid " - "function must have one and only one trigger " - "registered.") + "Function test_validate_function_missing_trigger" + " does not have a trigger. A valid function must have" + " one and only one trigger registered.") def test_validate_function_trigger_not_in_bindings(self): + def test_validate_function_trigger_not_in_bindings(): + return "dummy" + + self.dummy = test_validate_function_trigger_not_in_bindings + self.fb = FunctionBuilder(self.dummy, "dummy.py") + trigger = HttpTrigger(name='req', methods=(HttpMethod.GET,), data_type=DataType.UNDEFINED, auth_level=AuthLevel.ANONYMOUS, @@ -147,37 +160,59 @@ class TestFunctionBuilder(unittest.TestCase): getattr(self.fb, "_function").get_bindings().clear() self.fb.build() - self.assertEqual(err.exception.args[0], - f"Function dummy trigger {trigger} not present" - f" in bindings {[]}") + self.assertEqual( + err.exception.args[0], + f"Function test_validate_function_trigger_not_in_bindings" + f" trigger {trigger} not present" + f" in bindings {[]}") def test_validate_function_working(self): + def test_validate_function_working(): + return "dummy" + + self.dummy = test_validate_function_working + self.fb = FunctionBuilder(self.dummy, "dummy.py") + trigger = HttpTrigger(name='req', methods=(HttpMethod.GET,), data_type=DataType.UNDEFINED, - auth_level=AuthLevel.ANONYMOUS) + auth_level=AuthLevel.ANONYMOUS, + route='test_validate_function_working') self.fb.add_trigger(trigger) self.fb.build() def test_build_function_http_route_default(self): + def test_build_function_http_route_default(): + return "dummy" + + self.dummy = test_build_function_http_route_default + self.fb = FunctionBuilder(self.dummy, "dummy.py") + trigger = HttpTrigger(name='req', methods=(HttpMethod.GET,), data_type=DataType.UNDEFINED, auth_level=AuthLevel.ANONYMOUS) self.fb.add_trigger(trigger) func = self.fb.build() - self.assertEqual(func.get_trigger().route, "dummy") + self.assertEqual(func.get_trigger().route, + "test_build_function_http_route_default") def test_build_function_with_bindings(self): + def test_build_function_with_bindings(): + return "dummy" + + self.dummy = test_build_function_with_bindings + self.fb = FunctionBuilder(self.dummy, "dummy.py") + test_trigger = HttpTrigger(name='req', methods=(HttpMethod.GET,), data_type=DataType.UNDEFINED, - auth_level=AuthLevel.ANONYMOUS, - route='dummy') + auth_level=AuthLevel.ANONYMOUS) test_input = HttpOutput(name='out', data_type=DataType.UNDEFINED) func = self.fb.add_trigger( test_trigger).add_binding(test_input).build() - self.assertEqual(func.get_function_name(), "dummy") + self.assertEqual(func.get_function_name(), + "test_build_function_with_bindings") assert_json(self, func, { "scriptFile": "dummy.py", "bindings": [ @@ -187,7 +222,7 @@ class TestFunctionBuilder(unittest.TestCase): "direction": BindingDirection.IN, "name": "req", "dataType": DataType.UNDEFINED, - "route": "dummy", + "route": "test_build_function_with_bindings", "methods": [ HttpMethod.GET ] @@ -202,6 +237,12 @@ class TestFunctionBuilder(unittest.TestCase): }) def test_build_function_with_function_app_auth_level(self): + def test_build_function_with_function_app_auth_level(): + return "dummy" + + self.dummy = test_build_function_with_function_app_auth_level + self.fb = FunctionBuilder(self.dummy, "dummy.py") + trigger = HttpTrigger(name='req', methods=(HttpMethod.GET,), data_type=DataType.UNDEFINED) self.fb.add_trigger(trigger) @@ -210,11 +251,19 @@ class TestFunctionBuilder(unittest.TestCase): self.assertEqual(func.get_trigger().auth_level, AuthLevel.ANONYMOUS) def test_build_function_with_retry_policy_setting(self): + def test_build_function_with_retry_policy_setting(): + return "dummy" + + self.dummy = test_build_function_with_retry_policy_setting + self.fb = FunctionBuilder(self.dummy, "dummy.py") + setting = RetryPolicy(strategy="exponential", max_retry_count="2", minimum_interval="1", maximum_interval="5") - trigger = HttpTrigger(name='req', methods=(HttpMethod.GET,), - data_type=DataType.UNDEFINED, - auth_level=AuthLevel.ANONYMOUS) + trigger = HttpTrigger( + name='req', methods=(HttpMethod.GET,), + data_type=DataType.UNDEFINED, + auth_level=AuthLevel.ANONYMOUS, + route='test_build_function_with_retry_policy_setting') self.fb.add_trigger(trigger) self.fb.add_setting(setting) func = self.fb.build() @@ -224,6 +273,293 @@ class TestFunctionBuilder(unittest.TestCase): 'strategy': 'exponential', 'max_retry_count': '2', 'minimum_interval': '1', 'maximum_interval': '5'}) + def test_unique_method_names(self): + app = FunctionApp() + + @app.schedule(arg_name="name", schedule="10****") + def test_unique_method_names(name: str): + return name + + @app.schedule(arg_name="name", schedule="10****") + def test_unique_method_names2(name: str): + return name + + functions = app.get_functions() + self.assertEqual(len(functions), 2) + + self.assertEqual(functions[0].get_function_name(), + "test_unique_method_names") + self.assertEqual(functions[1].get_function_name(), + "test_unique_method_names2") + self.assertIsInstance(app._function_builders[0].function_bindings.get( + "test_unique_method_names")[0], TimerTrigger) + self.assertIsInstance(app._function_builders[0].function_bindings.get( + "test_unique_method_names2")[0], TimerTrigger) + + def test_unique_function_names(self): + app = FunctionApp() + + @app.function_name("test_unique_function_names") + @app.schedule(arg_name="name", schedule="10****") + def test_unique_function_names(name: str): + return name + + @app.function_name("test_unique_function_names2") + @app.schedule(arg_name="name", schedule="10****") + def test_unique_function_names2(name: str): + return name + + functions = app.get_functions() + self.assertEqual(len(functions), 2) + + self.assertEqual(functions[0].get_function_name(), + "test_unique_function_names") + self.assertEqual(functions[1].get_function_name(), + "test_unique_function_names2") + self.assertIsInstance(app._function_builders[0].function_bindings.get( + "test_unique_function_names")[0], TimerTrigger) + self.assertIsInstance(app._function_builders[0].function_bindings.get( + "test_unique_function_names2")[0], TimerTrigger) + + def test_same_method_names(self): + app = FunctionApp() + + @app.schedule(arg_name="name", schedule="10****") # NoQA + def test_same_method_names(name: str): # NoQA + return name + + @app.schedule(arg_name="name", schedule="10****") # NoQA + def test_same_method_names(name: str): # NoQA + return name + + with self.assertRaises(ValueError) as err: + app.get_functions() + self.assertEqual(err.exception.args[0], + "Function test_same_method_names does not have" + " a unique function name." + " Please change @app.function_name()" + " or the function method name to be unique.") + + def test_same_function_names(self): + app = FunctionApp() + + @app.function_name("test_same_function_names") # NoQA + @app.schedule(arg_name="name", schedule="10****") + def test_same_function_names(name: str): + return name + + @app.function_name("test_same_function_names") # NoQA + @app.schedule(arg_name="name", schedule="10****") + def test_same_function_names(name: str): # NoQA + return name + + with self.assertRaises(ValueError) as err: + app.get_functions() + self.assertEqual(err.exception.args[0], + "Function test_same_function_names does not have" + " a unique function name." + " Please change @app.function_name()" + " or the function method name to be unique.") + + def test_same_function_name_different_method_name(self): + app = FunctionApp() + + @app.function_name("test_same_function_name_different_method_name") + @app.schedule(arg_name="name", schedule="10****") + def test_same_function_name_different_method_name(name: str): + return name + + @app.function_name("test_same_function_name_different_method_name") + @app.schedule(arg_name="name", schedule="10****") + def test_same_function_name_different_method_name2(name: str): + return name + + with self.assertRaises(ValueError) as err: + app.get_functions() + self.assertEqual( + err.exception.args[0], + "Function test_same_function_name_different_method_name" + " does not have a unique function name." + " Please change @app.function_name()" + " or the function method name to be unique.") + + def test_same_function_and_method_name(self): + app = FunctionApp() + + @app.function_name("test_same_function_and_method_name") + @app.schedule(arg_name="name", schedule="10****") + def test_same_function_and_method_name2(name: str): + return name + + @app.schedule(arg_name="name", schedule="10****") + def test_same_function_and_method_name(name: str): + return name + + with self.assertRaises(ValueError) as err: + app.get_functions() + self.assertEqual(err.exception.args[0], + "Function test_same_function_and_method_name" + " does not have a unique function name." + " Please change @app.function_name()" + " or the function method name to be unique.") + + def test_blueprint_unique_method_names(self): + app = FunctionApp() + + @app.schedule(arg_name="name", schedule="10****") + def test_blueprint_unique_method_names(name: str): + return name + + bp = Blueprint() + + @bp.schedule(arg_name="name", schedule="10****") + def test_blueprint_unique_method_names2(name: str): + return name + + app.register_blueprint(bp) + + functions = app.get_functions() + self.assertEqual(len(functions), 2) + + self.assertEqual(functions[0].get_function_name(), + "test_blueprint_unique_method_names") + self.assertEqual(functions[1].get_function_name(), + "test_blueprint_unique_method_names2") + self.assertIsInstance(app._function_builders[0].function_bindings.get( + "test_blueprint_unique_method_names")[0], TimerTrigger) + self.assertIsInstance(app._function_builders[0].function_bindings.get( + "test_blueprint_unique_method_names2")[0], TimerTrigger) + + def test_blueprint_unique_function_names(self): + app = FunctionApp() + + @app.function_name("test_blueprint_unique_function_names") + @app.schedule(arg_name="name", schedule="10****") + def test_blueprint_unique_function_names(name: str): + return name + + bp = Blueprint() + + @bp.function_name("test_blueprint_unique_function_names2") + @bp.schedule(arg_name="name", schedule="10****") + def test_blueprint_unique_function_names2(name: str): + return name + + app.register_blueprint(bp) + + functions = app.get_functions() + self.assertEqual(len(functions), 2) + + self.assertEqual(functions[0].get_function_name(), + "test_blueprint_unique_function_names") + self.assertEqual(functions[1].get_function_name(), + "test_blueprint_unique_function_names2") + self.assertIsInstance(app._function_builders[0].function_bindings.get( + "test_blueprint_unique_function_names")[0], TimerTrigger) + self.assertIsInstance(app._function_builders[0].function_bindings.get( + "test_blueprint_unique_function_names2")[0], TimerTrigger) + + def test_blueprint_same_method_names(self): + app = FunctionApp() + + @app.schedule(arg_name="name", schedule="10****") # NoQA + def test_blueprint_same_method_names(name: str): # NoQA + return name + + bp = Blueprint() + + @bp.schedule(arg_name="name", schedule="10****") # NoQA + def test_blueprint_same_method_names(name: str): # NoQA + return name + + app.register_blueprint(bp) + + with self.assertRaises(ValueError) as err: + app.get_functions() + self.assertEqual(err.exception.args[0], + "Function test_blueprint_same_method_names" + " does not have a unique function name." + " Please change @app.function_name()" + " or the function method name to be unique.") + + def test_blueprint_same_function_names(self): + app = FunctionApp() + + @app.function_name("test_blueprint_same_function_names") # NoQA + @app.schedule(arg_name="name", schedule="10****") + def test_blueprint_same_function_names(name: str): # NoQA + return name + + bp = Blueprint() + + @bp.function_name("test_blueprint_same_function_names") # NoQA + @bp.schedule(arg_name="name", schedule="10****") + def test_blueprint_same_function_names(name: str): # NoQA + return name + + app.register_blueprint(bp) + + with self.assertRaises(ValueError) as err: + app.get_functions() + self.assertEqual(err.exception.args[0], + "Function test_blueprint_same_function_names" + " does not have a unique function name. Please change" + " @app.function_name() or the function method name" + " to be unique.") + + def test_blueprint_same_function_name_different_method_name(self): + app = FunctionApp() + + @app.function_name( + "test_blueprint_same_function_name_different_method_name") + @app.schedule(arg_name="name", schedule="10****") + def test_blueprint_same_function_name_different_method_name(name: str): + return name + + bp = Blueprint() + + @bp.function_name( + "test_blueprint_same_function_name_different_method_name") + @bp.schedule(arg_name="name", schedule="10****") + def test_blueprint_same_function_name_different_method_name2( + name: str): + return name + + app.register_blueprint(bp) + + with self.assertRaises(ValueError) as err: + app.get_functions() + self.assertEqual( + err.exception.args[0], + "Function test_blueprint_same_function_name_different_method_name" + " does not have a unique function name. Please change" + " @app.function_name() or the function method name to be unique.") + + def test_blueprint_same_function_and_method_name(self): + app = FunctionApp() + + @app.function_name("test_blueprint_same_function_and_method_name") + @app.schedule(arg_name="name", schedule="10****") + def test_blueprint_same_function_and_method_name2(name: str): + return name + + bp = Blueprint() + + @bp.schedule(arg_name="name", schedule="10****") + def test_blueprint_same_function_and_method_name(name: str): + return name + + app.register_blueprint(bp) + + with self.assertRaises(ValueError) as err: + app.get_functions() + self.assertEqual( + err.exception.args[0], + "Function test_blueprint_same_function_and_method_name" + " does not have a unique function name." + " Please change @app.function_name() or the function" + " method name to be unique.") + class TestScaffold(unittest.TestCase): def setUp(self): @@ -249,12 +585,12 @@ class TestScaffold(unittest.TestCase): def test_dummy_app_trigger(self): @self.dummy.dummy_trigger(name="dummy") - def dummy(): + def test_dummy_app_trigger(): return "dummy" self.assertEqual(len(self.dummy._function_builders), 1) func = self.dummy._function_builders[0].build() - self.assertEqual(func.get_function_name(), "dummy") + self.assertEqual(func.get_function_name(), "test_dummy_app_trigger") self.assertEqual(func.get_function_json(), '{"scriptFile": "function_app.py", "bindings": [{' '"direction": "IN", "dataType": "UNDEFINED", ' @@ -308,7 +644,7 @@ class TestFunctionApp(unittest.TestCase): '._add_http_app') def test_add_asgi(self, add_http_app_mock): mock_asgi_app = object() - AsgiFunctionApp(app=mock_asgi_app) + AsgiFunctionApp(app=mock_asgi_app, function_name='test_add_asgi') add_http_app_mock.assert_called_once() @@ -319,7 +655,7 @@ class TestFunctionApp(unittest.TestCase): '._add_http_app') def test_add_wsgi(self, add_http_app_mock): mock_wsgi_app = object() - WsgiFunctionApp(app=mock_wsgi_app) + WsgiFunctionApp(app=mock_wsgi_app, function_name='test_add_wsgi') add_http_app_mock.assert_called_once() self.assertIsInstance(add_http_app_mock.call_args[0][0], @@ -335,10 +671,18 @@ class TestFunctionApp(unittest.TestCase): self.assertTrue(issubclass(WsgiFunctionApp, ExternalHttpFunctionApp)) def test_add_asgi_app(self): - self._test_http_external_app(AsgiFunctionApp(app=object()), True) + self._test_http_external_app(AsgiFunctionApp( + app=object(), + function_name='test_add_asgi_app'), + True, + function_name='test_add_asgi_app') def test_add_wsgi_app(self): - self._test_http_external_app(WsgiFunctionApp(app=object()), False) + self._test_http_external_app(WsgiFunctionApp( + app=object(), + function_name='test_add_wsgi_app'), + False, + function_name='test_add_wsgi_app') def test_register_function_app_error(self): with self.assertRaises(TypeError) as err: @@ -351,7 +695,7 @@ class TestFunctionApp(unittest.TestCase): bp = Blueprint() @bp.schedule(arg_name="name", schedule="10****") - def hello(name: str): + def test_register_blueprint(name: str): return "hello" app = FunctionApp() @@ -365,14 +709,15 @@ class TestFunctionApp(unittest.TestCase): bp = Blueprint() @bp.route("name") - def hello(name: str): + def test_register_app_auth_level(name: str): return "hello" app = FunctionApp(http_auth_level=AuthLevel.ANONYMOUS) app.register_blueprint(bp) - self.assertEqual(len(app.get_functions()), 1) - self.assertEqual(app.get_functions()[0].get_trigger().auth_level, + functions = app.get_functions() + self.assertEqual(len(functions), 1) + self.assertEqual(functions[0].get_trigger().auth_level, AuthLevel.ANONYMOUS) def test_default_function_http_type(self): @@ -393,12 +738,12 @@ class TestFunctionApp(unittest.TestCase): @app.route("name1") @app.http_type("dummy1") - def hello(name: str): + def test_set_http_type(name: str): return "hello" @app.route("name2") @app.http_type("dummy2") - def hello2(name: str): + def test_set_http_type2(name: str): return "hello" funcs = app.get_functions() @@ -471,7 +816,7 @@ class TestFunctionApp(unittest.TestCase): blueprint = Blueprint() @blueprint.schedule(arg_name="name", schedule="10****") - def hello(name: str): + def test_function_register_non_http_function_app(name: str): return name app.register_blueprint(blueprint) @@ -511,7 +856,7 @@ class TestFunctionApp(unittest.TestCase): app = DummyFunctionApp(auth_level=AuthLevel.ANONYMOUS) blueprint = LegacyBluePrint() - @blueprint.function_name("timer_function") + @blueprint.function_name("test_legacy_blueprints_with_function_name") @blueprint.schedule(arg_name="name", schedule="10****") def hello(name: str): return name @@ -524,7 +869,7 @@ class TestFunctionApp(unittest.TestCase): setting = functions[0].get_setting("function_name") self.assertEqual(setting.get_settings_value("function_name"), - "timer_function") + "test_legacy_blueprints_with_function_name") def test_function_register_register_function_register_error(self): class DummyFunctionApp(FunctionRegister): @@ -545,7 +890,8 @@ class TestFunctionApp(unittest.TestCase): blueprint = Blueprint() @blueprint.schedule(arg_name="name", schedule="10****") - def hello(name: str): + def test_function_register_register_functions_from_blueprint( + name: str): return name app.register_blueprint(blueprint) @@ -558,7 +904,9 @@ class TestFunctionApp(unittest.TestCase): self.assertEqual(trigger.type, TIMER_TRIGGER) self.assertEqual(trigger.schedule, "10****") self.assertEqual(trigger.name, "name") - self.assertEqual(functions[0].get_function_name(), "hello") + self.assertEqual( + functions[0].get_function_name(), + "test_function_register_register_functions_from_blueprint") self.assertEqual(functions[0].get_user_function()("timer"), "timer") def test_asgi_function_app_default(self): @@ -587,7 +935,9 @@ class TestFunctionApp(unittest.TestCase): self.assertEqual(app.auth_level, AuthLevel.ANONYMOUS) def test_wsgi_function_app_is_http_function(self): - app = WsgiFunctionApp(app=object()) + app = WsgiFunctionApp( + app=object(), + function_name='test_wsgi_function_app_is_http_function') funcs = app.get_functions() self.assertEqual(len(funcs), 1) @@ -618,11 +968,11 @@ class TestFunctionApp(unittest.TestCase): app = ExternalHttpFunctionApp(auth_level=AuthLevel.ANONYMOUS) app._add_http_app(AsgiMiddleware(object())) - def _test_http_external_app(self, app, is_async): + def _test_http_external_app(self, app, is_async, function_name): funcs = app.get_functions() self.assertEqual(len(funcs), 1) func = funcs[0] - self.assertEqual(func.get_function_name(), "http_app_func") + self.assertEqual(func.get_function_name(), function_name) raw_bindings = func.get_raw_bindings() raw_trigger = raw_bindings[0] raw_output_binding = raw_bindings[0] From a0b869207d9d8cb185c15fc12b0241d3f6394de6 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:24:16 -0500 Subject: [PATCH 25/34] Update Python Library Version to 1.21.0b1 (#235) Co-authored-by: AzureFunctionsPython --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 57511eb..25eaf78 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -98,4 +98,4 @@ __all__ = ( 'BlobSource' ) -__version__ = '1.20.0' +__version__ = '1.21.0b1' From 961e9147747dc218b283f40b255daefdc6d83521 Mon Sep 17 00:00:00 2001 From: Vivek Jilla Date: Thu, 8 Aug 2024 00:06:52 +0530 Subject: [PATCH 26/34] feat: decorator support for Kafka extension (#234) * initial changes for decorator support for Kafka * Fixing minor issues * fixing tests * some more changes post validations * Added Oauthbearer options support and fixing tests --- azure/functions/decorators/constants.py | 2 + azure/functions/decorators/function_app.py | 316 +++++++++++++++++++++ azure/functions/decorators/kafka.py | 155 ++++++++++ tests/decorators/test_kafka.py | 104 +++++++ 4 files changed, 577 insertions(+) create mode 100644 azure/functions/decorators/kafka.py create mode 100644 tests/decorators/test_kafka.py diff --git a/azure/functions/decorators/constants.py b/azure/functions/decorators/constants.py index 2787cb2..8d699b9 100644 --- a/azure/functions/decorators/constants.py +++ b/azure/functions/decorators/constants.py @@ -8,6 +8,8 @@ EVENT_HUB_TRIGGER = "eventHubTrigger" EVENT_HUB = "eventHub" HTTP_TRIGGER = "httpTrigger" HTTP_OUTPUT = "http" +KAFKA = "kafka" +KAFKA_TRIGGER = "kafkaTrigger" QUEUE = "queue" QUEUE_TRIGGER = "queueTrigger" SERVICE_BUS = "serviceBus" diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 14af303..feaa760 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -24,6 +24,8 @@ from azure.functions.decorators.eventgrid import EventGridTrigger, \ from azure.functions.decorators.eventhub import EventHubTrigger, EventHubOutput from azure.functions.decorators.http import HttpTrigger, HttpOutput, \ HttpMethod +from azure.functions.decorators.kafka import KafkaTrigger, KafkaOutput, \ + BrokerAuthenticationMode, BrokerProtocol, OAuthBearerMethod from azure.functions.decorators.queue import QueueTrigger, QueueOutput from azure.functions.decorators.servicebus import ServiceBusQueueTrigger, \ ServiceBusQueueOutput, ServiceBusTopicTrigger, \ @@ -1229,6 +1231,155 @@ class TriggerApi(DecoratorApi, ABC): return wrap + def kafka_trigger(self, + arg_name: str, + topic: str, + broker_list: str, + event_hub_connection_string: Optional[str] = None, + consumer_group: Optional[str] = None, + avro_schema: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + ssl_key_location: Optional[str] = None, + ssl_ca_location: Optional[str] = None, + ssl_certificate_location: Optional[str] = None, + ssl_key_password: Optional[str] = None, + schema_registry_url: Optional[str] = None, + schema_registry_username: Optional[str] = None, + schema_registry_password: Optional[str] = None, + o_auth_bearer_method: Optional[Union[OAuthBearerMethod, str]] = None, # noqa E501 + o_auth_bearer_client_id: Optional[str] = None, + o_auth_bearer_client_secret: Optional[str] = None, + o_auth_bearer_scope: Optional[str] = None, + o_auth_bearer_token_endpoint_url: Optional[str] = None, + o_auth_bearer_extensions: Optional[str] = None, + authentication_mode: Optional[Union[BrokerAuthenticationMode, str]] = "NotSet", # noqa E501 + protocol: Optional[Union[BrokerProtocol, str]] = "NotSet", # noqa E501 + cardinality: Optional[Union[Cardinality, str]] = "One", + lag_threshold: int = 1000, + data_type: Optional[Union[DataType, str]] = None, + **kwargs) -> Callable[..., Any]: + """ + The kafka_trigger decorator adds + :class:`KafkaTrigger` + to the :class:`FunctionBuilder` object + for building :class:`Function` object used in worker function + indexing model. This is equivalent to defining kafka trigger + in the function.json which enables function to be triggered to + respond to an event sent to a kafka topic. + All optional fields will be given default value by function host when + they are parsed by function host. + + Ref: https://aka.ms/kafkatrigger + + :param arg_name: the variable name used in function code for the + parameter that has the kafka event data. + :param topic: The topic monitored by the trigger. + :param broker_list: The list of Kafka brokers monitored by the trigger. + :param event_hub_connection_string: The name of an app setting that + contains the connection string for the eventhub when using Kafka + protocol header feature of Azure EventHubs. + :param consumer_group: Kafka consumer group used by the trigger. + :param avro_schema: This should be used only if a generic record + should be generated. + :param username: SASL username for use with the PLAIN and SASL-SCRAM-.. + mechanisms. Default is empty string. This is equivalent to + 'sasl.username' in librdkafka. + :param password: SASL password for use with the PLAIN and SASL-SCRAM-.. + mechanisms. Default is empty string. This is equivalent to + 'sasl.password' in librdkafka. + :param ssl_key_location: Path to client's private key (PEM) used for + authentication. Default is empty string. This is equivalent to + 'ssl.key.location' in librdkafka. + :param ssl_ca_location: Path to CA certificate file for verifying the + broker's certificate. This is equivalent to 'ssl.ca.location' in + librdkafka. + :param ssl_certificate_location: Path to client's certificate. This is + equivalent to 'ssl.certificate.location' in librdkafka. + :param ssl_key_password: Password for client's certificate. This is + equivalent to 'ssl.key.password' in librdkafka. + :param schema_registry_url: URL for the Avro Schema Registry. + :param schema_registry_username: Username for the Avro Schema Registry. + :param schema_registry_password: Password for the Avro Schema Registry. + :param o_auth_bearer_method: Either 'default' or 'oidc'. + sasl.oauthbearer in librdkafka. + :param o_auth_bearer_client_id: Specify only when o_auth_bearer_method + is 'oidc'. sasl.oauthbearer.client.id in librdkafka. + :param o_auth_bearer_client_secret: Specify only when + o_auth_bearer_method is 'oidc'. sasl.oauthbearer.client.secret in + librdkafka. + :param o_auth_bearer_scope: Specify only when o_auth_bearer_method + is 'oidc'. Client use this to specify the scope of the access request + to the broker. sasl.oauthbearer.scope in librdkafka. + :param o_auth_bearer_token_endpoint_url: Specify only when + o_auth_bearer_method is 'oidc'. sasl.oauthbearer.token.endpoint.url + in librdkafka. + :param o_auth_bearer_extensions: Allow additional information to be + provided to the broker. Comma-separated list of key=value pairs. E.g., + "supportFeatureX=true,organizationId=sales-emea". + sasl.oauthbearer.extensions in librdkafka + :param authentication_mode: SASL mechanism to use for authentication. + Allowed values: Gssapi, Plain, ScramSha256, ScramSha512. Default is + Plain. This is equivalent to 'sasl.mechanism' in librdkafka. + :param protocol: Gets or sets the security protocol used to communicate + with brokers. Default is plain text. This is equivalent to + 'security.protocol' in librdkafka. TODO + :param lag_threshold: Maximum number of unprocessed messages a worker + is expected to have at an instance. When target-based scaling is not + disabled, this is used to divide total unprocessed event count to + determine the number of worker instances, which will then be rounded + up to a worker instance count that creates a balanced partition + distribution. Default is 1000. + :param data_type: Defines how Functions runtime should treat the + parameter value. + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_trigger( + trigger=KafkaTrigger( + name=arg_name, + topic=topic, + broker_list=broker_list, + event_hub_connection_string=event_hub_connection_string, # noqa: E501 + consumer_group=consumer_group, + avro_schema=avro_schema, + username=username, + password=password, + ssl_key_location=ssl_key_location, + ssl_ca_location=ssl_ca_location, + ssl_certificate_location=ssl_certificate_location, + ssl_key_password=ssl_key_password, + schema_registry_url=schema_registry_url, + schema_registry_username=schema_registry_username, + schema_registry_password=schema_registry_password, + o_auth_bearer_method=parse_singular_param_to_enum( + o_auth_bearer_method, OAuthBearerMethod), + o_auth_bearer_client_id=o_auth_bearer_client_id, + o_auth_bearer_client_secret=o_auth_bearer_client_secret, # noqa: E501 + o_auth_bearer_scope=o_auth_bearer_scope, + o_auth_bearer_token_endpoint_url=o_auth_bearer_token_endpoint_url, # noqa: E501 + o_auth_bearer_extensions=o_auth_bearer_extensions, + authentication_mode=parse_singular_param_to_enum( + authentication_mode, BrokerAuthenticationMode), + protocol=parse_singular_param_to_enum(protocol, + BrokerProtocol), + cardinality=parse_singular_param_to_enum(cardinality, + Cardinality), + lag_threshold=lag_threshold, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + def sql_trigger(self, arg_name: str, table_name: str, @@ -2212,6 +2363,171 @@ class BindingApi(DecoratorApi, ABC): return wrap + def kafka_output(self, + arg_name: str, + topic: str, + broker_list: str, + avro_schema: Optional[str] = None, + username: Optional[str] = None, + password: Optional[str] = None, + ssl_key_location: Optional[str] = None, + ssl_ca_location: Optional[str] = None, + ssl_certificate_location: Optional[str] = None, + ssl_key_password: Optional[str] = None, + schema_registry_url: Optional[str] = None, + schema_registry_username: Optional[str] = None, + schema_registry_password: Optional[str] = None, + o_auth_bearer_method: Optional[Union[OAuthBearerMethod, str]] = None, # noqa E501 + o_auth_bearer_client_id: Optional[str] = None, + o_auth_bearer_client_secret: Optional[str] = None, + o_auth_bearer_scope: Optional[str] = None, + o_auth_bearer_token_endpoint_url: Optional[str] = None, + o_auth_bearer_extensions: Optional[str] = None, + max_message_bytes: int = 1_000_000, + batch_size: int = 10_000, + enable_idempotence: bool = False, + message_timeout_ms: int = 300_000, + request_timeout_ms: int = 5_000, + max_retries: int = 2_147_483_647, + authentication_mode: Optional[Union[BrokerAuthenticationMode, str]] = "NOTSET", # noqa E501 + protocol: Optional[Union[BrokerProtocol, str]] = "NOTSET", + linger_ms: int = 5, + data_type: Optional[Union[DataType, str]] = None, + **kwargs) -> Callable[..., Any]: + """ + The kafka_output decorator adds + :class:`KafkaOutput` + to the :class:`FunctionBuilder` object + for building :class:`Function` object used in worker function + indexing model. This is equivalent to defining output binding + in the function.json which enables function to + write events to a kafka topic. + All optional fields will be given default value by function host when + they are parsed by function host. + + Ref: https://aka.ms/kafkaoutput + + :param arg_name: The variable name used in function code that + represents the event. + :param topic: The topic monitored by the trigger. + :param broker_list: The list of Kafka brokers monitored by the trigger. + :param avro_schema: This should be used only if a generic record + should be generated. + :param username: SASL username for use with the PLAIN and SASL-SCRAM-.. + mechanisms. Default is empty string. This is equivalent to + 'sasl.username' in librdkafka. + :param password: SASL password for use with the PLAIN and SASL-SCRAM-.. + mechanisms. Default is empty string. This is equivalent to + 'sasl.password' in librdkafka. + :param ssl_key_location: Path to client's private key (PEM) used for + authentication. Default is empty string. This is equivalent to + 'ssl.key.location' in librdkafka. + :param ssl_ca_location: Path to CA certificate file for verifying the + broker's certificate. This is equivalent to 'ssl.ca.location' in + librdkafka. + :param ssl_certificate_location: Path to client's certificate. This is + equivalent to 'ssl.certificate.location' in librdkafka. + :param ssl_key_password: Password for client's certificate. This is + equivalent to 'ssl.key.password' in librdkafka. + :param schema_registry_url: URL for the Avro Schema Registry. + :param schema_registry_username: Username for the Avro Schema Registry. + :param schema_registry_password: Password for the Avro Schema Registry. + :param o_auth_bearer_method: Either 'default' or 'oidc'. + sasl.oauthbearer in librdkafka. + :param o_auth_bearer_client_id: Specify only when o_auth_bearer_method + is 'oidc'. sasl.oauthbearer.client.id in librdkafka. + :param o_auth_bearer_client_secret: Specify only when + o_auth_bearer_method is 'oidc'. sasl.oauthbearer.client.secret in + librdkafka. + :param o_auth_bearer_scope: Specify only when o_auth_bearer_method + is 'oidc'. Client use this to specify the scope of the access request + to the broker. sasl.oauthbearer.scope in librdkafka. + :param o_auth_bearer_token_endpoint_url: Specify only when + o_auth_bearer_method is 'oidc'. sasl.oauthbearer.token.endpoint.url + in librdkafka. + :param o_auth_bearer_extensions: Allow additional information to be + provided to the broker. Comma-separated list of key=value pairs. E.g., + "supportFeatureX=true,organizationId=sales-emea". + sasl.oauthbearer.extensions in librdkafka + :param max_message_bytes: Maximum transmit message size. Default is 1MB + :param batch_size: Maximum number of messages batched in one MessageSet + Default is 10000. + :param enable_idempotence: When set to `true`, the producer will ensure + that messages are successfully produced exactly once and in the + original produce order. Default is false. + :param message_timeout_ms: Local message timeout. This value is only + enforced locally and limits the time a produced message waits for + successful delivery. A time of 0 is infinite. This is the maximum time + used to deliver a message (including retries). Delivery error occurs + when either the retry count or the message timeout are exceeded. + Default is 300000. + :param request_timeout_ms: The ack timeout of the producer request in + milliseconds. Default is 5000. + :param max_retries: How many times to retry sending a failing Message. + Default is 2147483647. Retrying may cause reordering unless + 'EnableIdempotence' is set to 'True'. + :param authentication_mode: SASL mechanism to use for authentication. + Allowed values: Gssapi, Plain, ScramSha256, ScramSha512. Default is + Plain. This is equivalent to 'sasl.mechanism' in librdkafka. + :param protocol: Gets or sets the security protocol used to communicate + with brokers. Default is plain text. This is equivalent to + 'security.protocol' in librdkafka. + :param linger_ms: Linger.MS property provides the time between batches + of messages being sent to cluster. Larger value allows more batching + results in high throughput. + :param data_type: Defines how Functions runtime should treat the + parameter value. + :param kwargs: Keyword arguments for specifying additional binding + fields to include in the binding json + + :return: Decorator function. + """ + + @self._configure_function_builder + def wrap(fb): + def decorator(): + fb.add_binding( + binding=KafkaOutput( + name=arg_name, + topic=topic, + broker_list=broker_list, + avro_schema=avro_schema, + username=username, + password=password, + ssl_key_location=ssl_key_location, + ssl_ca_location=ssl_ca_location, + ssl_certificate_location=ssl_certificate_location, + ssl_key_password=ssl_key_password, + schema_registry_url=schema_registry_url, + schema_registry_username=schema_registry_username, + schema_registry_password=schema_registry_password, + o_auth_bearer_method=parse_singular_param_to_enum( + o_auth_bearer_method, OAuthBearerMethod), + o_auth_bearer_client_id=o_auth_bearer_client_id, + o_auth_bearer_client_secret=o_auth_bearer_client_secret, # noqa: E501 + o_auth_bearer_scope=o_auth_bearer_scope, + o_auth_bearer_token_endpoint_url=o_auth_bearer_token_endpoint_url, # noqa: E501 + o_auth_bearer_extensions=o_auth_bearer_extensions, + max_message_bytes=max_message_bytes, + batch_size=batch_size, + enable_idempotence=enable_idempotence, + message_timeout_ms=message_timeout_ms, + request_timeout_ms=request_timeout_ms, + max_retries=max_retries, + authentication_mode=parse_singular_param_to_enum( + authentication_mode, BrokerAuthenticationMode), + protocol=parse_singular_param_to_enum(protocol, + BrokerProtocol), + linger_ms=linger_ms, + data_type=parse_singular_param_to_enum(data_type, + DataType), + **kwargs)) + return fb + + return decorator() + + return wrap + def table_input(self, arg_name: str, connection: str, diff --git a/azure/functions/decorators/kafka.py b/azure/functions/decorators/kafka.py new file mode 100644 index 0000000..3e726f8 --- /dev/null +++ b/azure/functions/decorators/kafka.py @@ -0,0 +1,155 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +from typing import Optional + +from azure.functions.decorators.constants import KAFKA, KAFKA_TRIGGER +from azure.functions.decorators.core import Cardinality, DataType, \ + OutputBinding, Trigger +from .utils import StringifyEnum + + +class BrokerAuthenticationMode(StringifyEnum): + NOTSET = -1 + GSSAPI = 0 + PLAIN = 1 + SCRAMSHA256 = 2 + SCRAMSHA512 = 3 + + +class BrokerProtocol(StringifyEnum): + NOTSET = -1 + PLAINTEXT = 0 + SSL = 1 + SASLPLAINTEXT = 2 + SASLSSL = 3 + + +class OAuthBearerMethod(StringifyEnum): + DEFAULT = 0, + OIDC = 1 + + +class KafkaOutput(OutputBinding): + @staticmethod + def get_binding_name() -> str: + return KAFKA + + def __init__(self, + name: str, + topic: str, + broker_list: str, + avro_schema: Optional[str], + username: Optional[str], + password: Optional[str], + ssl_key_location: Optional[str], + ssl_ca_location: Optional[str], + ssl_certificate_location: Optional[str], + ssl_key_password: Optional[str], + schema_registry_url: Optional[str], + schema_registry_username: Optional[str], + schema_registry_password: Optional[str], + o_auth_bearer_method: Optional[OAuthBearerMethod] = None, + o_auth_bearer_client_id: Optional[str] = None, + o_auth_bearer_client_secret: Optional[str] = None, + o_auth_bearer_scope: Optional[str] = None, + o_auth_bearer_token_endpoint_url: Optional[str] = None, + o_auth_bearer_extensions: Optional[str] = None, + max_message_bytes: int = 1_000_000, + batch_size: int = 10_000, + enable_idempotence: bool = False, + message_timeout_ms: int = 300_000, + request_timeout_ms: int = 5_000, + max_retries: int = 2_147_483_647, + authentication_mode: Optional[BrokerAuthenticationMode] = BrokerAuthenticationMode.NOTSET, # noqa: E501 + protocol: Optional[BrokerProtocol] = BrokerProtocol.NOTSET, + linger_ms: int = 5, + data_type: Optional[DataType] = None, + **kwargs): + self.topic = topic + self.broker_list = broker_list + self.avro_schema = avro_schema + self.username = username + self.password = password + self.ssl_key_location = ssl_key_location + self.ssl_ca_location = ssl_ca_location + self.ssl_certificate_location = ssl_certificate_location + self.ssl_key_password = ssl_key_password + self.schema_registry_url = schema_registry_url + self.schema_registry_username = schema_registry_username + self.schema_registry_password = schema_registry_password + self.o_auth_bearer_method = o_auth_bearer_method + self.o_auth_bearer_client_id = o_auth_bearer_client_id + self.o_auth_bearer_client_secret = o_auth_bearer_client_secret + self.o_auth_bearer_scope = o_auth_bearer_scope + self.o_auth_bearer_token_endpoint_url = o_auth_bearer_token_endpoint_url # noqa: E501 + self.o_auth_bearer_extensions = o_auth_bearer_extensions + self.max_message_bytes = max_message_bytes + self.batch_size = batch_size + self.enable_idempotence = enable_idempotence + self.message_timeout_ms = message_timeout_ms + self.request_timeout_ms = request_timeout_ms + self.max_retries = max_retries + self.authentication_mode = authentication_mode + self.protocol = protocol + self.linger_ms = linger_ms + super().__init__(name=name, data_type=data_type) + + +class KafkaTrigger(Trigger): + @staticmethod + def get_binding_name() -> str: + return KAFKA_TRIGGER + + def __init__(self, + name: str, + topic: str, + broker_list: str, + event_hub_connection_string: Optional[str], + consumer_group: Optional[str], + avro_schema: Optional[str], + username: Optional[str], + password: Optional[str], + ssl_key_location: Optional[str], + ssl_ca_location: Optional[str], + ssl_certificate_location: Optional[str], + ssl_key_password: Optional[str], + schema_registry_url: Optional[str], + schema_registry_username: Optional[str], + schema_registry_password: Optional[str], + o_auth_bearer_method: Optional[OAuthBearerMethod] = None, + o_auth_bearer_client_id: Optional[str] = None, + o_auth_bearer_client_secret: Optional[str] = None, + o_auth_bearer_scope: Optional[str] = None, + o_auth_bearer_token_endpoint_url: Optional[str] = None, + o_auth_bearer_extensions: Optional[str] = None, + authentication_mode: Optional[BrokerAuthenticationMode] = BrokerAuthenticationMode.NOTSET, # noqa: E501 + protocol: Optional[BrokerProtocol] = BrokerProtocol.NOTSET, + cardinality: Optional[Cardinality] = Cardinality.ONE, + lag_threshold: int = 1000, + data_type: Optional[DataType] = None, + **kwargs): + self.topic = topic + self.broker_list = broker_list + self.event_hub_connection_string = event_hub_connection_string + self.consumer_group = consumer_group + self.avro_schema = avro_schema + self.username = username + self.password = password + self.ssl_key_location = ssl_key_location + self.ssl_ca_location = ssl_ca_location + self.ssl_certificate_location = ssl_certificate_location + self.ssl_key_password = ssl_key_password + self.schema_registry_url = schema_registry_url + self.schema_registry_username = schema_registry_username + self.schema_registry_password = schema_registry_password + self.o_auth_bearer_method = o_auth_bearer_method + self.o_auth_bearer_client_id = o_auth_bearer_client_id + self.o_auth_bearer_client_secret = o_auth_bearer_client_secret + self.o_auth_bearer_scope = o_auth_bearer_scope + self.o_auth_bearer_token_endpoint_url = o_auth_bearer_token_endpoint_url # noqa: E501 + self.o_auth_bearer_extensions = o_auth_bearer_extensions + self.authentication_mode = authentication_mode + self.protocol = protocol + self.cardinality = cardinality + self.lag_threshold = lag_threshold + super().__init__(name=name, data_type=data_type) diff --git a/tests/decorators/test_kafka.py b/tests/decorators/test_kafka.py new file mode 100644 index 0000000..409df27 --- /dev/null +++ b/tests/decorators/test_kafka.py @@ -0,0 +1,104 @@ +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. +import unittest + +from azure.functions.decorators.constants import KAFKA_TRIGGER, KAFKA +from azure.functions.decorators.core import BindingDirection, Cardinality, \ + DataType +from azure.functions.decorators.kafka import KafkaTrigger, KafkaOutput, \ + BrokerAuthenticationMode, BrokerProtocol + + +class TestKafka(unittest.TestCase): + def test_kafka_trigger_valid_creation(self): + trigger = KafkaTrigger(name="arg_name", + topic="topic", + broker_list="broker_list", + event_hub_connection_string="ehcs", + consumer_group="consumer_group", + avro_schema="avro_schema", + username="username", + password="password", + ssl_key_location="ssl_key_location", + ssl_ca_location="ssl_ca_location", + ssl_certificate_location="scl", + ssl_key_password="ssl_key_password", + schema_registry_url="srurl", + schema_registry_username="sruser", + schema_registry_password="srp", + authentication_mode=BrokerAuthenticationMode.PLAIN, # noqa: E501 + data_type=DataType.UNDEFINED, + dummy_field="dummy") + + self.assertEqual(trigger.get_binding_name(), "kafkaTrigger") + self.assertEqual(trigger.get_dict_repr(), + {"authenticationMode": BrokerAuthenticationMode.PLAIN, + "avroSchema": "avro_schema", + "brokerList": "broker_list", + "consumerGroup": "consumer_group", + "dataType": DataType.UNDEFINED, + "direction": BindingDirection.IN, + "dummyField": "dummy", + "eventHubConnectionString": "ehcs", + "lagThreshold": 1000, + "name": "arg_name", + "password": "password", + "protocol": BrokerProtocol.NOTSET, + "schemaRegistryPassword": "srp", + "schemaRegistryUrl": "srurl", + "schemaRegistryUsername": "sruser", + "sslCaLocation": "ssl_ca_location", + "sslCertificateLocation": "scl", + "sslKeyLocation": "ssl_key_location", + "sslKeyPassword": "ssl_key_password", + "topic": "topic", + "cardinality": Cardinality.ONE, + "type": KAFKA_TRIGGER, + "username": "username"}) + + def test_kafka_output_valid_creation(self): + output = KafkaOutput(name="arg_name", + topic="topic", + broker_list="broker_list", + avro_schema="avro_schema", + username="username", + password="password", + ssl_key_location="ssl_key_location", + ssl_ca_location="ssl_ca_location", + ssl_certificate_location="scl", + ssl_key_password="ssl_key_password", + schema_registry_url="schema_registry_url", + schema_registry_username="sru", + schema_registry_password="srp", + max_retries=10, + data_type=DataType.UNDEFINED, + dummy_field="dummy") + + self.assertEqual(output.get_binding_name(), "kafka") + self.assertEqual(output.get_dict_repr(), + {'authenticationMode': BrokerAuthenticationMode.NOTSET, # noqa: E501 + 'avroSchema': 'avro_schema', + 'batchSize': 10000, + 'brokerList': 'broker_list', + 'dataType': DataType.UNDEFINED, + 'direction': BindingDirection.OUT, + 'dummyField': 'dummy', + 'enableIdempotence': False, + 'lingerMs': 5, + 'maxMessageBytes': 1000000, + 'maxRetries': 10, + 'messageTimeoutMs': 300000, + 'name': 'arg_name', + 'password': 'password', + 'protocol': BrokerProtocol.NOTSET, + 'requestTimeoutMs': 5000, + 'schemaRegistryPassword': 'srp', + 'schemaRegistryUrl': 'schema_registry_url', + 'schemaRegistryUsername': 'sru', + 'sslCaLocation': 'ssl_ca_location', + 'sslCertificateLocation': 'scl', + 'sslKeyLocation': 'ssl_key_location', + 'sslKeyPassword': 'ssl_key_password', + 'topic': 'topic', + 'type': KAFKA, + 'username': 'username'}) From 0617cd7bf1de9ce63e0e5021f208138e17b75ab9 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Wed, 7 Aug 2024 14:37:57 -0500 Subject: [PATCH 27/34] refactor: unique function name validation (#236) * set functions_bindings to None * refactor name tracker to FunctionRegister * lint * renamed validate_functions * feedback * missed test fix * missed test fix * lint * fix tests * replace kafka test value * remove kafka test value --------- Co-authored-by: Victoria Hall --- azure/functions/decorators/function_app.py | 32 +++++++----- tests/decorators/test_function_app.py | 57 +++++++++++++++------- tests/decorators/test_kafka.py | 8 +-- 3 files changed, 65 insertions(+), 32 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index feaa760..33ca06d 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -206,7 +206,6 @@ class Function(object): class FunctionBuilder(object): - function_bindings: dict = {} def __init__(self, func, function_script_file): self._function = Function(func, function_script_file) @@ -272,16 +271,6 @@ class FunctionBuilder(object): parse_singular_param_to_enum(auth_level, AuthLevel)) self._function._is_http_function = True - # This dict contains the function name and its bindings for all - # functions in an app. If a previous function has the same name, - # indexing will fail here. - if self.function_bindings.get(function_name, None): - raise ValueError( - f"Function {function_name} does not have a unique" - f" function name. Please change @app.function_name() or" - f" the function method name to be unique.") - self.function_bindings[function_name] = bindings - def build(self, auth_level: Optional[AuthLevel] = None) -> Function: """ Validates and builds the function object. @@ -3592,6 +3581,7 @@ class FunctionRegister(DecoratorApi, HttpFunctionsAuthLevelMixin, ABC): DecoratorApi.__init__(self, *args, **kwargs) HttpFunctionsAuthLevelMixin.__init__(self, auth_level, *args, **kwargs) self._require_auth_level: Optional[bool] = None + self.functions_bindings: Optional[Dict[Any, Any]] = None def get_functions(self) -> List[Function]: """Get the function objects in the function app. @@ -3613,8 +3603,28 @@ class FunctionRegister(DecoratorApi, HttpFunctionsAuthLevelMixin, ABC): '-bindings-http-webhook-trigger?tabs=in-process' '%2Cfunctionsv2&pivots=programming-language-python#http-auth') + self.validate_function_names(functions=functions) + return functions + def validate_function_names(self, functions: List[Function]): + """The functions_bindings dict contains the function name and + its bindings for all functions in an app. If a previous function + has the same name, indexing will fail here. + """ + if not self.functions_bindings: + self.functions_bindings = {} + for function in functions: + function_name = function.get_function_name() + if function_name in self.functions_bindings: + raise ValueError( + f"Function {function_name} does not have a unique" + f" function name. Please change @app.function_name() or" + f" the function method name to be unique.") + # The value of the key doesn't matter. We're using a dict for + # faster lookup times. + self.functions_bindings[function_name] = True + def register_functions(self, function_container: DecoratorApi) -> None: """Register a list of functions in the function app. diff --git a/tests/decorators/test_function_app.py b/tests/decorators/test_function_app.py index b2cd0d9..0f646cb 100644 --- a/tests/decorators/test_function_app.py +++ b/tests/decorators/test_function_app.py @@ -20,7 +20,6 @@ from azure.functions.decorators.function_app import ( ) from azure.functions.decorators.http import HttpTrigger, HttpOutput, \ HttpMethod -from azure.functions.decorators.timer import TimerTrigger from azure.functions.decorators.retry_policy import RetryPolicy from test_core import DummyTrigger from tests.utils.testutils import assert_json @@ -291,10 +290,6 @@ class TestFunctionBuilder(unittest.TestCase): "test_unique_method_names") self.assertEqual(functions[1].get_function_name(), "test_unique_method_names2") - self.assertIsInstance(app._function_builders[0].function_bindings.get( - "test_unique_method_names")[0], TimerTrigger) - self.assertIsInstance(app._function_builders[0].function_bindings.get( - "test_unique_method_names2")[0], TimerTrigger) def test_unique_function_names(self): app = FunctionApp() @@ -316,10 +311,6 @@ class TestFunctionBuilder(unittest.TestCase): "test_unique_function_names") self.assertEqual(functions[1].get_function_name(), "test_unique_function_names2") - self.assertIsInstance(app._function_builders[0].function_bindings.get( - "test_unique_function_names")[0], TimerTrigger) - self.assertIsInstance(app._function_builders[0].function_bindings.get( - "test_unique_function_names2")[0], TimerTrigger) def test_same_method_names(self): app = FunctionApp() @@ -425,10 +416,6 @@ class TestFunctionBuilder(unittest.TestCase): "test_blueprint_unique_method_names") self.assertEqual(functions[1].get_function_name(), "test_blueprint_unique_method_names2") - self.assertIsInstance(app._function_builders[0].function_bindings.get( - "test_blueprint_unique_method_names")[0], TimerTrigger) - self.assertIsInstance(app._function_builders[0].function_bindings.get( - "test_blueprint_unique_method_names2")[0], TimerTrigger) def test_blueprint_unique_function_names(self): app = FunctionApp() @@ -454,10 +441,6 @@ class TestFunctionBuilder(unittest.TestCase): "test_blueprint_unique_function_names") self.assertEqual(functions[1].get_function_name(), "test_blueprint_unique_function_names2") - self.assertIsInstance(app._function_builders[0].function_bindings.get( - "test_blueprint_unique_function_names")[0], TimerTrigger) - self.assertIsInstance(app._function_builders[0].function_bindings.get( - "test_blueprint_unique_function_names2")[0], TimerTrigger) def test_blueprint_same_method_names(self): app = FunctionApp() @@ -1009,3 +992,43 @@ class TestFunctionApp(unittest.TestCase): "type": HTTP_OUTPUT } ]}) + + +class TestFunctionRegister(unittest.TestCase): + def test_validate_empty_dict(self): + def dummy(): + return "dummy" + + test_func = Function(dummy, "dummy.py") + fr = FunctionRegister(auth_level="ANONYMOUS") + fr.validate_function_names(functions=[test_func]) + + def test_validate_unique_names(self): + def dummy(): + return "dummy" + + def dummy2(): + return "dummy" + + test_func = Function(dummy, "dummy.py") + test_func2 = Function(dummy2, "dummy.py") + + fr = FunctionRegister(auth_level="ANONYMOUS") + fr.validate_function_names( + functions=[test_func, test_func2]) + + def test_validate_non_unique_names(self): + def dummy(): + return "dummy" + + test_func = Function(dummy, "dummy.py") + test_func2 = Function(dummy, "dummy.py") + + fr = FunctionRegister(auth_level="ANONYMOUS") + with self.assertRaises(ValueError) as err: + fr.validate_function_names(functions=[test_func, test_func2]) + self.assertEqual(err.exception.args[0], + "Function dummy does not have" + " a unique function name." + " Please change @app.function_name()" + " or the function method name to be unique.") diff --git a/tests/decorators/test_kafka.py b/tests/decorators/test_kafka.py index 409df27..6f0257e 100644 --- a/tests/decorators/test_kafka.py +++ b/tests/decorators/test_kafka.py @@ -24,7 +24,7 @@ class TestKafka(unittest.TestCase): ssl_certificate_location="scl", ssl_key_password="ssl_key_password", schema_registry_url="srurl", - schema_registry_username="sruser", + schema_registry_username="", schema_registry_password="srp", authentication_mode=BrokerAuthenticationMode.PLAIN, # noqa: E501 data_type=DataType.UNDEFINED, @@ -46,7 +46,7 @@ class TestKafka(unittest.TestCase): "protocol": BrokerProtocol.NOTSET, "schemaRegistryPassword": "srp", "schemaRegistryUrl": "srurl", - "schemaRegistryUsername": "sruser", + "schemaRegistryUsername": "", "sslCaLocation": "ssl_ca_location", "sslCertificateLocation": "scl", "sslKeyLocation": "ssl_key_location", @@ -68,7 +68,7 @@ class TestKafka(unittest.TestCase): ssl_certificate_location="scl", ssl_key_password="ssl_key_password", schema_registry_url="schema_registry_url", - schema_registry_username="sru", + schema_registry_username="", schema_registry_password="srp", max_retries=10, data_type=DataType.UNDEFINED, @@ -94,7 +94,7 @@ class TestKafka(unittest.TestCase): 'requestTimeoutMs': 5000, 'schemaRegistryPassword': 'srp', 'schemaRegistryUrl': 'schema_registry_url', - 'schemaRegistryUsername': 'sru', + 'schemaRegistryUsername': '', 'sslCaLocation': 'ssl_ca_location', 'sslCertificateLocation': 'scl', 'sslKeyLocation': 'ssl_key_location', From b6202298a404cc354b6d7ca396b2c7ea76f0039d Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Wed, 7 Aug 2024 15:43:33 -0500 Subject: [PATCH 28/34] Update Python Library Version to 1.21.0b2 (#237) Co-authored-by: AzureFunctionsPython --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 25eaf78..293416c 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -98,4 +98,4 @@ __all__ = ( 'BlobSource' ) -__version__ = '1.21.0b1' +__version__ = '1.21.0b2' From 3873bab9485ade68dd33aa4f78b70d87675e7288 Mon Sep 17 00:00:00 2001 From: Varad Meru Date: Mon, 19 Aug 2024 12:45:45 -0500 Subject: [PATCH 29/34] feat: Adding ability to invoke functions directly (#238) * Adding new direct calling tests * Adding comments and improving tests --- azure/functions/decorators/function_app.py | 26 ++++++++++++++++++---- tests/decorators/test_function_app.py | 16 +++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/azure/functions/decorators/function_app.py b/azure/functions/decorators/function_app.py index 33ca06d..773bf5d 100644 --- a/azure/functions/decorators/function_app.py +++ b/azure/functions/decorators/function_app.py @@ -75,6 +75,26 @@ class Function(object): self.http_type = 'function' self._is_http_function = False + def __str__(self): + """Return the function.json representation of the function""" + return self.get_function_json() + + def __call__(self, *args, **kwargs): + """This would allow the Function object to be directly callable and runnable + directly using the interpreter locally. + + Example: + @app.route(route="http_trigger") + def http_trigger(req: func.HttpRequest) -> func.HttpResponse: + return "Hello, World!" + + print(http_trigger(None)) + + ➜ python function_app.py + Hello, World! + """ + return self._func(*args, **kwargs) + def add_binding(self, binding: Binding) -> None: """Add a binding instance to the function. @@ -201,9 +221,6 @@ class Function(object): """ return json.dumps(self.get_dict_repr(), cls=StringifyEnumJsonEncoder) - def __str__(self): - return self.get_function_json() - class FunctionBuilder(object): @@ -211,7 +228,8 @@ class FunctionBuilder(object): self._function = Function(func, function_script_file) def __call__(self, *args, **kwargs): - pass + """Call the Function object directly""" + return self._function(*args, **kwargs) def configure_http_type(self, http_type: str) -> 'FunctionBuilder': self._function.set_http_type(http_type) diff --git a/tests/decorators/test_function_app.py b/tests/decorators/test_function_app.py index 0f646cb..4617e69 100644 --- a/tests/decorators/test_function_app.py +++ b/tests/decorators/test_function_app.py @@ -543,6 +543,22 @@ class TestFunctionBuilder(unittest.TestCase): " Please change @app.function_name() or the function" " method name to be unique.") + def test_user_function_is_directly_callable_no_args(self): + def test_validate_function_working_no_args(): + return "dummy" + + self.dummy = test_validate_function_working_no_args + self.fb = FunctionBuilder(self.dummy, "dummy.py") + self.assertEqual(self.fb(), "dummy") + + def test_user_function_is_directly_callable_args(self): + def test_validate_function_working_sum_args(arg1: int, arg2: int): + return arg1 + arg2 + + self.dummy = test_validate_function_working_sum_args + self.fb = FunctionBuilder(self.dummy, "dummy.py") + self.assertEqual(self.fb(1, 2), 3) + class TestScaffold(unittest.TestCase): def setUp(self): From 2e5ea4fa04ddaea1d19872fe73b96f709e799268 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Wed, 28 Aug 2024 14:09:42 -0500 Subject: [PATCH 30/34] build: update Python Library Version to 1.21.0b3 (#241) Co-authored-by: AzureFunctionsPython --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 293416c..7de175d 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -98,4 +98,4 @@ __all__ = ( 'BlobSource' ) -__version__ = '1.21.0b2' +__version__ = '1.21.0b3' From 839bfbbc2fb5836cba80fd7aeaf5cffe555abe7c Mon Sep 17 00:00:00 2001 From: wangbill Date: Mon, 23 Sep 2024 10:55:22 -0700 Subject: [PATCH 31/34] chore: allow-forked-pr-to-run-labeler (#245) --- .github/workflows/label.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index 29fe3b6..3274d56 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -1,6 +1,6 @@ name: "Pull Request Labeler" on: - pull_request: + pull_request_target: paths: - '**/__init__.py' jobs: From 87cd09230fc9f35e90857025fcfb124271c9e48f Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Tue, 24 Sep 2024 12:12:56 -0500 Subject: [PATCH 32/34] build: update Python Library Version to 1.21.0 (#246) Co-authored-by: AzureFunctionsPython --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 7de175d..86565e3 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -98,4 +98,4 @@ __all__ = ( 'BlobSource' ) -__version__ = '1.21.0b3' +__version__ = '1.21.0' From 3f8e43ce44d698a1127cb58d2e27f825fd944937 Mon Sep 17 00:00:00 2001 From: hallvictoria <59299039+hallvictoria@users.noreply.github.com> Date: Mon, 30 Sep 2024 14:38:30 -0500 Subject: [PATCH 33/34] cherry pick changes (#249) Co-authored-by: Victoria Hall --- azure/functions/decorators/blob.py | 2 +- tests/decorators/test_blob.py | 6 +++--- tests/decorators/test_decorators.py | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/azure/functions/decorators/blob.py b/azure/functions/decorators/blob.py index 1a2d412..bd2861f 100644 --- a/azure/functions/decorators/blob.py +++ b/azure/functions/decorators/blob.py @@ -17,7 +17,7 @@ class BlobTrigger(Trigger): **kwargs): self.path = path self.connection = connection - self.source = source + self.source = source.value if source else None super().__init__(name=name, data_type=data_type) @staticmethod diff --git a/tests/decorators/test_blob.py b/tests/decorators/test_blob.py index f8712b9..4392659 100644 --- a/tests/decorators/test_blob.py +++ b/tests/decorators/test_blob.py @@ -42,7 +42,7 @@ class TestBlob(unittest.TestCase): "name": "req", "dataType": DataType.UNDEFINED, "path": "dummy_path", - 'source': BlobSource.LOGS_AND_CONTAINER_SCAN, + 'source': 'LogsAndContainerScan', "connection": "dummy_connection" }) @@ -62,7 +62,7 @@ class TestBlob(unittest.TestCase): "name": "req", "dataType": DataType.UNDEFINED, "path": "dummy_path", - 'source': BlobSource.EVENT_GRID, + 'source': 'EventGrid', "connection": "dummy_connection" }) @@ -82,7 +82,7 @@ class TestBlob(unittest.TestCase): "name": "req", "dataType": DataType.UNDEFINED, "path": "dummy_path", - 'source': BlobSource.EVENT_GRID, + 'source': 'EventGrid', "connection": "dummy_connection" }) diff --git a/tests/decorators/test_decorators.py b/tests/decorators/test_decorators.py index 82973ba..acdd5cc 100644 --- a/tests/decorators/test_decorators.py +++ b/tests/decorators/test_decorators.py @@ -1628,7 +1628,7 @@ class TestFunctionsApp(unittest.TestCase): "type": BLOB_TRIGGER, "name": "req", "path": "dummy_path", - "source": BlobSource.EVENT_GRID, + "source": 'EventGrid', "connection": "dummy_conn" }) From 52a5bbe85785c10caab8cbfdf5590d2d93ff7eeb Mon Sep 17 00:00:00 2001 From: AzureFunctionsPython Date: Mon, 30 Sep 2024 19:42:03 +0000 Subject: [PATCH 34/34] build: update Python Library Version to 1.21.1 --- azure/functions/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure/functions/__init__.py b/azure/functions/__init__.py index 86565e3..e7add80 100644 --- a/azure/functions/__init__.py +++ b/azure/functions/__init__.py @@ -98,4 +98,4 @@ __all__ = ( 'BlobSource' ) -__version__ = '1.21.0' +__version__ = '1.21.1'