From 49d86de538010e1457f8b121a7f55897fdbec252 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 2 Jun 2025 12:57:15 -0500 Subject: [PATCH 1/8] update typing inspect --- .../_thirdparty/typing_inspect.py | 4 ++ .../http_functions_stein/function_app.py | 40 +++++++++++++++++ .../generic/function_app.py | 45 +++++++++++++++++++ tests/endtoend/test_http_functions.py | 28 +++++++++++- tests/unittests/test_typing_inspect.py | 3 ++ 5 files changed, 119 insertions(+), 1 deletion(-) diff --git a/azure_functions_worker/_thirdparty/typing_inspect.py b/azure_functions_worker/_thirdparty/typing_inspect.py index f5ae783d2..4872779d2 100644 --- a/azure_functions_worker/_thirdparty/typing_inspect.py +++ b/azure_functions_worker/_thirdparty/typing_inspect.py @@ -165,6 +165,10 @@ def get_origin(tp): return tp.__origin__ if tp.__origin__ is not ClassVar else None if tp is Generic: return Generic + if (isinstance(tp, type) and issubclass(tp, Generic) + or ((isinstance(tp, _GenericAlias) or isinstance(tp, _SpecialGenericAlias)) # NoQA E501 + and tp.__origin__ not in (Union, tuple, ClassVar, collections.abc.Callable))): # NoQA E501 + return Generic return None diff --git a/tests/endtoend/http_functions/http_functions_stein/function_app.py b/tests/endtoend/http_functions/http_functions_stein/function_app.py index f60697475..cc388c98f 100644 --- a/tests/endtoend/http_functions/http_functions_stein/function_app.py +++ b/tests/endtoend/http_functions/http_functions_stein/function_app.py @@ -1,14 +1,30 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import json import logging import time + from datetime import datetime +from typing import Generic, Mapping, Optional, TypeVar, Union import azure.functions as func app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +JsonType = Union[list, tuple, dict, str, int, float, bool] +T = TypeVar("T", bound=JsonType) + + +class JsonResponse(Generic[T], func.HttpResponse): + def __init__( + self, + body: T, + status_code: int = 200, + headers: Optional[Mapping[str, str]] = None, + ): + headers = (headers or {}) | {"Content-Type": "application/json"} + super().__init__(json.dumps(body), status_code=status_code, headers=headers, charset="utf-8") @app.route(route="default_template") def default_template(req: func.HttpRequest) -> func.HttpResponse: @@ -42,3 +58,27 @@ def http_func(req: func.HttpRequest) -> func.HttpResponse: current_time = datetime.now().strftime("%H:%M:%S") return func.HttpResponse(f"{current_time}") + + +@app.route(route="custom_response") +def custom_response(req: func.HttpRequest) -> JsonResponse: + name = req.params.get('name') + if not name: + try: + req_body = req.get_json() + except ValueError: + pass + else: + name = req_body.get('name') + if name: + return JsonResponse( + { + "name": name + }, + ) + else: + return JsonResponse( + { + "status": "healthy" + }, + ) diff --git a/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py b/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py index 17e715a89..8e8659c02 100644 --- a/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py +++ b/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py @@ -1,13 +1,31 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. +import json import logging +from typing import Generic, Mapping, Optional, TypeVar, Union + import azure.functions as func app = func.FunctionApp(http_auth_level=func.AuthLevel.ANONYMOUS) +JsonType = Union[list, tuple, dict, str, int, float, bool] +T = TypeVar("T", bound=JsonType) + + +class JsonResponse(Generic[T], func.HttpResponse): + def __init__( + self, + body: T, + status_code: int = 200, + headers: Optional[Mapping[str, str]] = None, + ): + headers = (headers or {}) | {"Content-Type": "application/json"} + super().__init__(json.dumps(body), status_code=status_code, headers=headers, charset="utf-8") + + @app.function_name(name="default_template") @app.generic_trigger(arg_name="req", type="httpTrigger", @@ -36,3 +54,30 @@ def default_template(req: func.HttpRequest) -> func.HttpResponse: " personalized response.", status_code=200 ) + + +@app.generic_trigger(arg_name="req", + type="httpTrigger", + route="custom_response") +@app.generic_output_binding(arg_name="$return", type="http") +def custom_response(req: func.HttpRequest) -> JsonResponse: + name = req.params.get('name') + if not name: + try: + req_body = req.get_json() + except ValueError: + pass + else: + name = req_body.get('name') + if name: + return JsonResponse( + { + "name": name + }, + ) + else: + return JsonResponse( + { + "status": "healthy" + }, + ) diff --git a/tests/endtoend/test_http_functions.py b/tests/endtoend/test_http_functions.py index 3128dfd38..d94206092 100644 --- a/tests/endtoend/test_http_functions.py +++ b/tests/endtoend/test_http_functions.py @@ -118,8 +118,34 @@ def get_script_dir(cls): return testutils.E2E_TESTS_FOLDER / 'http_functions' / \ 'http_functions_stein' + @testutils.retryable_test(3, 5) + def test_return_custom_class(self): + """Test if returning a custom class returns OK + """ + r = self.webhost.request('GET', 'custom_response', + timeout=REQUEST_TIMEOUT_SEC) + self.assertEqual( + r.content, + {'status': 'healthy'} + ) + self.assertTrue(r.ok) + + @testutils.retryable_test(3, 5) + def test_return_custom_class_with_query_param(self): + """Test if the azure.functions SDK is able to deserialize query + parameter from the default template + """ + r = self.webhost.request('GET', 'custom_response', + params={'name': 'query'}, + timeout=REQUEST_TIMEOUT_SEC) + self.assertTrue(r.ok) + self.assertEqual( + r.content, + {'name': 'query'} + ) + -class TestHttpFunctionsSteinGeneric(TestHttpFunctions): +class TestHttpFunctionsSteinGeneric(TestHttpFunctionsStein): @classmethod def get_script_dir(cls): diff --git a/tests/unittests/test_typing_inspect.py b/tests/unittests/test_typing_inspect.py index 4f01e4c73..8f43c5fd2 100644 --- a/tests/unittests/test_typing_inspect.py +++ b/tests/unittests/test_typing_inspect.py @@ -98,11 +98,14 @@ class GetUtilityTestCase(TestCase): def test_origin(self): T = TypeVar('T') + class MyClass(Generic[T]): pass + self.assertEqual(get_origin(int), None) self.assertEqual(get_origin(ClassVar[int]), None) self.assertEqual(get_origin(Generic), Generic) self.assertEqual(get_origin(Generic[T]), Generic) self.assertEqual(get_origin(List[Tuple[T, T]][int]), list) + self.assertEqual(get_origin(MyClass), Generic) def test_parameters(self): T = TypeVar('T') From d96710a17f178b932c58c529769fb8a5709b9098 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 2 Jun 2025 15:36:48 -0500 Subject: [PATCH 2/8] Fix test --- .../http_functions/http_functions_stein/function_app.py | 1 - .../http_functions_stein/generic/function_app.py | 1 - tests/endtoend/test_http_functions.py | 3 +-- tests/unittests/test_broken_functions.py | 3 +-- 4 files changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/endtoend/http_functions/http_functions_stein/function_app.py b/tests/endtoend/http_functions/http_functions_stein/function_app.py index cc388c98f..5e808b1c0 100644 --- a/tests/endtoend/http_functions/http_functions_stein/function_app.py +++ b/tests/endtoend/http_functions/http_functions_stein/function_app.py @@ -23,7 +23,6 @@ def __init__( status_code: int = 200, headers: Optional[Mapping[str, str]] = None, ): - headers = (headers or {}) | {"Content-Type": "application/json"} super().__init__(json.dumps(body), status_code=status_code, headers=headers, charset="utf-8") @app.route(route="default_template") diff --git a/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py b/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py index 8e8659c02..a947fadb4 100644 --- a/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py +++ b/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py @@ -22,7 +22,6 @@ def __init__( status_code: int = 200, headers: Optional[Mapping[str, str]] = None, ): - headers = (headers or {}) | {"Content-Type": "application/json"} super().__init__(json.dumps(body), status_code=status_code, headers=headers, charset="utf-8") diff --git a/tests/endtoend/test_http_functions.py b/tests/endtoend/test_http_functions.py index d94206092..c210ce8e4 100644 --- a/tests/endtoend/test_http_functions.py +++ b/tests/endtoend/test_http_functions.py @@ -132,8 +132,7 @@ def test_return_custom_class(self): @testutils.retryable_test(3, 5) def test_return_custom_class_with_query_param(self): - """Test if the azure.functions SDK is able to deserialize query - parameter from the default template + """Test if query is accepted """ r = self.webhost.request('GET', 'custom_response', params={'name': 'query'}, diff --git a/tests/unittests/test_broken_functions.py b/tests/unittests/test_broken_functions.py index 508122c92..f75ce4d34 100644 --- a/tests/unittests/test_broken_functions.py +++ b/tests/unittests/test_broken_functions.py @@ -67,8 +67,7 @@ async def test_load_broken__bad_out_annotation(self): self.assertRegex( r.response.result.exception.message, - r'.*cannot load the bad_out_annotation function' - r'.*binding foo has invalid Out annotation.*') + 'binding foo is declared to have the \"out\" direction, but its annotation in Python is not a subclass of azure.functions.Out') async def test_load_broken__wrong_binding_dir(self): async with testutils.start_mockhost( From 2ca0acc0c754b545c0ff1ec93365d8d6230a5cc3 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 2 Jun 2025 15:57:08 -0500 Subject: [PATCH 3/8] lint --- .../http_functions/http_functions_stein/function_app.py | 6 +++++- .../http_functions_stein/generic/function_app.py | 5 ++++- tests/endtoend/test_http_functions.py | 2 +- tests/unittests/test_broken_functions.py | 4 +++- 4 files changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/endtoend/http_functions/http_functions_stein/function_app.py b/tests/endtoend/http_functions/http_functions_stein/function_app.py index 5e808b1c0..434f3c44c 100644 --- a/tests/endtoend/http_functions/http_functions_stein/function_app.py +++ b/tests/endtoend/http_functions/http_functions_stein/function_app.py @@ -23,7 +23,11 @@ def __init__( status_code: int = 200, headers: Optional[Mapping[str, str]] = None, ): - super().__init__(json.dumps(body), status_code=status_code, headers=headers, charset="utf-8") + super().__init__(json.dumps(body), + status_code=status_code, + headers=headers, + charset="utf-8") + @app.route(route="default_template") def default_template(req: func.HttpRequest) -> func.HttpResponse: diff --git a/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py b/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py index a947fadb4..84591ec33 100644 --- a/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py +++ b/tests/endtoend/http_functions/http_functions_stein/generic/function_app.py @@ -22,7 +22,10 @@ def __init__( status_code: int = 200, headers: Optional[Mapping[str, str]] = None, ): - super().__init__(json.dumps(body), status_code=status_code, headers=headers, charset="utf-8") + super().__init__(json.dumps(body), + status_code=status_code, + headers=headers, + charset="utf-8") @app.function_name(name="default_template") diff --git a/tests/endtoend/test_http_functions.py b/tests/endtoend/test_http_functions.py index c210ce8e4..6f571f7de 100644 --- a/tests/endtoend/test_http_functions.py +++ b/tests/endtoend/test_http_functions.py @@ -129,7 +129,7 @@ def test_return_custom_class(self): {'status': 'healthy'} ) self.assertTrue(r.ok) - + @testutils.retryable_test(3, 5) def test_return_custom_class_with_query_param(self): """Test if query is accepted diff --git a/tests/unittests/test_broken_functions.py b/tests/unittests/test_broken_functions.py index f75ce4d34..850cf5b69 100644 --- a/tests/unittests/test_broken_functions.py +++ b/tests/unittests/test_broken_functions.py @@ -67,7 +67,9 @@ async def test_load_broken__bad_out_annotation(self): self.assertRegex( r.response.result.exception.message, - 'binding foo is declared to have the \"out\" direction, but its annotation in Python is not a subclass of azure.functions.Out') + 'binding foo is declared to have the \"out\" direction,' + ' but its annotation in Python is not a' + ' subclass of azure.functions.Out') async def test_load_broken__wrong_binding_dir(self): async with testutils.start_mockhost( From df000da94a427bf4a1f159dd175b7994fc839a7e Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 2 Jun 2025 16:00:02 -0500 Subject: [PATCH 4/8] Fix for 3.9+ --- azure_functions_worker/_thirdparty/typing_inspect.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/azure_functions_worker/_thirdparty/typing_inspect.py b/azure_functions_worker/_thirdparty/typing_inspect.py index 4872779d2..bcb665678 100644 --- a/azure_functions_worker/_thirdparty/typing_inspect.py +++ b/azure_functions_worker/_thirdparty/typing_inspect.py @@ -165,10 +165,11 @@ def get_origin(tp): return tp.__origin__ if tp.__origin__ is not ClassVar else None if tp is Generic: return Generic - if (isinstance(tp, type) and issubclass(tp, Generic) - or ((isinstance(tp, _GenericAlias) or isinstance(tp, _SpecialGenericAlias)) # NoQA E501 - and tp.__origin__ not in (Union, tuple, ClassVar, collections.abc.Callable))): # NoQA E501 - return Generic + if NEW_39_TYPING: + if (isinstance(tp, type) and issubclass(tp, Generic) + or ((isinstance(tp, _GenericAlias) or isinstance(tp, _SpecialGenericAlias)) # NoQA E501 + and tp.__origin__ not in (Union, tuple, ClassVar, collections.abc.Callable))): # NoQA E501 + return Generic return None From eac305171ee5641184be65234b382024ca9068a5 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Mon, 2 Jun 2025 16:02:24 -0500 Subject: [PATCH 5/8] Fix tests for 3.9+ --- tests/unittests/test_typing_inspect.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/unittests/test_typing_inspect.py b/tests/unittests/test_typing_inspect.py index 8f43c5fd2..6a003ba5c 100644 --- a/tests/unittests/test_typing_inspect.py +++ b/tests/unittests/test_typing_inspect.py @@ -3,6 +3,7 @@ # Imported from https://github.com/ilevkivskyi/typing_inspect/blob/168fa6f7c5c55f720ce6282727211cf4cf6368f6/test_typing_inspect.py # Author: Ivan Levkivskyi # License: MIT +import sys from typing import ( Any, @@ -98,13 +99,18 @@ class GetUtilityTestCase(TestCase): def test_origin(self): T = TypeVar('T') - class MyClass(Generic[T]): pass self.assertEqual(get_origin(int), None) self.assertEqual(get_origin(ClassVar[int]), None) self.assertEqual(get_origin(Generic), Generic) self.assertEqual(get_origin(Generic[T]), Generic) self.assertEqual(get_origin(List[Tuple[T, T]][int]), list) + + skipIf(sys.version_info.minor < 9, "New generic support only for 3.9+") + def test_origin_39(self): + T = TypeVar('T') + class MyClass(Generic[T]): pass + self.assertEqual(get_origin(MyClass), Generic) def test_parameters(self): From da630ae1091cb83fad7ad65e07b003bd605fecb9 Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 3 Jun 2025 10:25:08 -0500 Subject: [PATCH 6/8] just add none check --- azure_functions_worker/_thirdparty/typing_inspect.py | 5 ----- azure_functions_worker/functions.py | 1 + tests/unittests/test_broken_functions.py | 5 ++--- tests/unittests/test_typing_inspect.py | 9 ++------- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/azure_functions_worker/_thirdparty/typing_inspect.py b/azure_functions_worker/_thirdparty/typing_inspect.py index bcb665678..f5ae783d2 100644 --- a/azure_functions_worker/_thirdparty/typing_inspect.py +++ b/azure_functions_worker/_thirdparty/typing_inspect.py @@ -165,11 +165,6 @@ def get_origin(tp): return tp.__origin__ if tp.__origin__ is not ClassVar else None if tp is Generic: return Generic - if NEW_39_TYPING: - if (isinstance(tp, type) and issubclass(tp, Generic) - or ((isinstance(tp, _GenericAlias) or isinstance(tp, _SpecialGenericAlias)) # NoQA E501 - and tp.__origin__ not in (Union, tuple, ClassVar, collections.abc.Callable))): # NoQA E501 - return Generic return None diff --git a/azure_functions_worker/functions.py b/azure_functions_worker/functions.py index 292fe4857..c876f589f 100644 --- a/azure_functions_worker/functions.py +++ b/azure_functions_worker/functions.py @@ -272,6 +272,7 @@ def get_function_return_type(annotations: dict, has_explicit_return: bool, return_anno = annotations.get('return') if typing_inspect.is_generic_type( return_anno) and typing_inspect.get_origin( + return_anno) is not None and typing_inspect.get_origin( return_anno).__name__ == 'Out': raise FunctionLoadError( func_name, diff --git a/tests/unittests/test_broken_functions.py b/tests/unittests/test_broken_functions.py index 850cf5b69..508122c92 100644 --- a/tests/unittests/test_broken_functions.py +++ b/tests/unittests/test_broken_functions.py @@ -67,9 +67,8 @@ async def test_load_broken__bad_out_annotation(self): self.assertRegex( r.response.result.exception.message, - 'binding foo is declared to have the \"out\" direction,' - ' but its annotation in Python is not a' - ' subclass of azure.functions.Out') + r'.*cannot load the bad_out_annotation function' + r'.*binding foo has invalid Out annotation.*') async def test_load_broken__wrong_binding_dir(self): async with testutils.start_mockhost( diff --git a/tests/unittests/test_typing_inspect.py b/tests/unittests/test_typing_inspect.py index 6a003ba5c..fbd93e9ca 100644 --- a/tests/unittests/test_typing_inspect.py +++ b/tests/unittests/test_typing_inspect.py @@ -99,19 +99,14 @@ class GetUtilityTestCase(TestCase): def test_origin(self): T = TypeVar('T') + class MyClass(Generic[T]): pass self.assertEqual(get_origin(int), None) self.assertEqual(get_origin(ClassVar[int]), None) self.assertEqual(get_origin(Generic), Generic) self.assertEqual(get_origin(Generic[T]), Generic) self.assertEqual(get_origin(List[Tuple[T, T]][int]), list) - - skipIf(sys.version_info.minor < 9, "New generic support only for 3.9+") - def test_origin_39(self): - T = TypeVar('T') - class MyClass(Generic[T]): pass - - self.assertEqual(get_origin(MyClass), Generic) + self.assertEqual(get_origin(MyClass), None) def test_parameters(self): T = TypeVar('T') From 8f2db1604601a96042afdba2052c18060e18aa1e Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 3 Jun 2025 10:26:11 -0500 Subject: [PATCH 7/8] lint --- tests/unittests/test_typing_inspect.py | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unittests/test_typing_inspect.py b/tests/unittests/test_typing_inspect.py index fbd93e9ca..a7d7a4d2d 100644 --- a/tests/unittests/test_typing_inspect.py +++ b/tests/unittests/test_typing_inspect.py @@ -3,7 +3,6 @@ # Imported from https://github.com/ilevkivskyi/typing_inspect/blob/168fa6f7c5c55f720ce6282727211cf4cf6368f6/test_typing_inspect.py # Author: Ivan Levkivskyi # License: MIT -import sys from typing import ( Any, From 6c704067d2f70f2799db92e44b6d3245576026db Mon Sep 17 00:00:00 2001 From: Victoria Hall Date: Tue, 3 Jun 2025 11:51:32 -0500 Subject: [PATCH 8/8] fix test --- tests/endtoend/test_http_functions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/endtoend/test_http_functions.py b/tests/endtoend/test_http_functions.py index 6f571f7de..b13827d18 100644 --- a/tests/endtoend/test_http_functions.py +++ b/tests/endtoend/test_http_functions.py @@ -126,7 +126,7 @@ def test_return_custom_class(self): timeout=REQUEST_TIMEOUT_SEC) self.assertEqual( r.content, - {'status': 'healthy'} + b'{"status": "healthy"}' ) self.assertTrue(r.ok) @@ -140,7 +140,7 @@ def test_return_custom_class_with_query_param(self): self.assertTrue(r.ok) self.assertEqual( r.content, - {'name': 'query'} + b'{"name": "query"}' )