From b81ef60f6172e256c2c366df7d6c035783df0f14 Mon Sep 17 00:00:00 2001 From: Zaar Hai Date: Thu, 30 Nov 2023 16:54:25 +1100 Subject: [PATCH 1/4] Support for postponed annotations Fixes #291 --- src/functions_framework/_typed_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py index 40e715ae..05e36027 100644 --- a/src/functions_framework/_typed_event.py +++ b/src/functions_framework/_typed_event.py @@ -31,7 +31,7 @@ def register_typed_event(decorator_type, func): try: - sig = signature(func) + sig = signature(func, eval_str=True) annotation_type = list(sig.parameters.values())[0].annotation input_type = _select_input_type(decorator_type, annotation_type) _validate_input_type(input_type) From b6e51c88317bfb078fe76ed3027f05d1a4a9d1f7 Mon Sep 17 00:00:00 2001 From: Zaar Hai Date: Thu, 30 Nov 2023 21:54:10 +1100 Subject: [PATCH 2/4] Support for native Pydantic models --- src/functions_framework/__init__.py | 13 ++++++++--- src/functions_framework/_typed_event.py | 23 +++++++++++++------ .../typed_events/pydantic_event.py | 19 +++++++++++++++ tests/test_typed_event_functions.py | 9 ++++++++ tox.ini | 1 + 5 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 tests/test_functions/typed_events/pydantic_event.py diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index ece4f446..ba1dbdd4 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -13,7 +13,6 @@ # limitations under the License. import functools -import inspect import io import json import logging @@ -22,7 +21,6 @@ import sys import types -from inspect import signature from typing import Callable, Type import cloudevents.exceptions as cloud_exceptions @@ -41,6 +39,12 @@ ) from google.cloud.functions.context import Context +try: + from pydantic import BaseModel +except ModuleNotFoundError: + BaseModel = types.NoneType + + _FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" _CRASH = "crash" @@ -146,7 +150,10 @@ def _typed_event_func_wrapper(function, request, inputType: Type): def view_func(path): try: data = request.get_json() - input = inputType.from_dict(data) + if issubclass(inputType, BaseModel): + input = inputType(**data) + else: + input = inputType.from_dict(data) response = function(input) if response is None: return "", 200 diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py index 05e36027..0830d283 100644 --- a/src/functions_framework/_typed_event.py +++ b/src/functions_framework/_typed_event.py @@ -14,12 +14,18 @@ import inspect +import types from inspect import signature from functions_framework import _function_registry from functions_framework.exceptions import FunctionsFrameworkException +try: + from pydantic import BaseModel +except ModuleNotFoundError: + BaseModel = types.NoneType + """Registers user function in the REGISTRY_MAP and the INPUT_TYPE_MAP. Also performs some validity checks for the input type of the function @@ -96,10 +102,13 @@ def _select_input_type(decorator_type, annotation_type): def _validate_input_type(input_type): - if not ( - hasattr(input_type, "from_dict") and callable(getattr(input_type, "from_dict")) - ): - raise AttributeError( - "The type {decorator_type} does not have the required method called " - " 'from_dict'.".format(decorator_type=input_type) - ) + if BaseModel and issubclass(input_type, BaseModel): + # Pydantic model - we are good + return + if (hasattr(input_type, "from_dict") and callable(getattr(input_type, "from_dict"))): + # Use our customer from/to_doct protocol - we are good + return + raise AttributeError( + "The type {decorator_type} is neither Pydantic model no has the required method called " + " 'from_dict'.".format(decorator_type=input_type) + ) diff --git a/tests/test_functions/typed_events/pydantic_event.py b/tests/test_functions/typed_events/pydantic_event.py new file mode 100644 index 00000000..bbec0d51 --- /dev/null +++ b/tests/test_functions/typed_events/pydantic_event.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import functions_framework +from pydantic import BaseModel + + +class TestType(BaseModel): + name: str + age: int + + +@functions_framework.typed +def function_typed_pydantic(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + return testType.model_dump() + + diff --git a/tests/test_typed_event_functions.py b/tests/test_typed_event_functions.py index 3b8d5da1..b1eb4bde 100644 --- a/tests/test_typed_event_functions.py +++ b/tests/test_typed_event_functions.py @@ -121,3 +121,12 @@ def test_missing_parameter_typed_decorator(): def test_missing_to_dict_typed_decorator(typed_decorator_missing_to_dict): resp = typed_decorator_missing_to_dict.post("/", json={"name": "john", "age": 10}) assert resp.status_code == 500 + + +def test_typed_decorator_pydantic(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "pydantic_event.py" + target = "function_typed_pydantic" + client = create_app(target, source).test_client() + resp = client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.json["name"] == "jane" and resp.json["age"] == 20 diff --git a/tox.ini b/tox.ini index e8c555b5..1efe5ecd 100644 --- a/tox.ini +++ b/tox.ini @@ -8,6 +8,7 @@ deps = pytest-cov pytest-integration pretend + pydantic setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = From 88000e4593be89132aa3b221c1a63c55b83068ae Mon Sep 17 00:00:00 2001 From: Zaar Hai Date: Thu, 30 Nov 2023 22:23:07 +1100 Subject: [PATCH 3/4] Compatibility with Python < 3.10 --- src/functions_framework/_typed_event.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py index 05e36027..6b0b1db5 100644 --- a/src/functions_framework/_typed_event.py +++ b/src/functions_framework/_typed_event.py @@ -12,10 +12,17 @@ # See the License for the specific language governing permissions and # limitations under the License. +import sys import inspect -from inspect import signature +from inspect import signature as _signature +if sys.version_info.major == 3 and sys.version_info.minor < 10: + signature = _signature +else: + from functools import partial + signature = partial(_signature, eval_str=True) + from functions_framework import _function_registry from functions_framework.exceptions import FunctionsFrameworkException From 5e3e445bdcb057518cc995df5225c8ca353f4f43 Mon Sep 17 00:00:00 2001 From: Zaar Hai Date: Thu, 30 Nov 2023 22:33:34 +1100 Subject: [PATCH 4/4] call singature cleanup --- src/functions_framework/_typed_event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py index 6b0b1db5..e47d8235 100644 --- a/src/functions_framework/_typed_event.py +++ b/src/functions_framework/_typed_event.py @@ -38,7 +38,7 @@ def register_typed_event(decorator_type, func): try: - sig = signature(func, eval_str=True) + sig = signature(func) annotation_type = list(sig.parameters.values())[0].annotation input_type = _select_input_type(decorator_type, annotation_type) _validate_input_type(input_type)