From 5daeb4aaf130f2714fe1a0f799b09cb01703d9bd Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Thu, 10 Nov 2022 11:18:31 -0800 Subject: [PATCH 1/6] test: increase start delay for buildpack integration test (#207) This will decrease the flakiness of the tests due to not being able to reach the FF container/server because it hasn't started up fully yet. --- .github/workflows/buildpack-integration-test.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/buildpack-integration-test.yml b/.github/workflows/buildpack-integration-test.yml index 29cff116..ba4aecb7 100644 --- a/.github/workflows/buildpack-integration-test.yml +++ b/.github/workflows/buildpack-integration-test.yml @@ -15,6 +15,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python37' + start-delay: 5 python38: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -24,6 +25,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python38' + start-delay: 5 python39: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -33,6 +35,7 @@ jobs: cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' builder-runtime: 'python39' + start-delay: 5 python310: uses: GoogleCloudPlatform/functions-framework-conformance/.github/workflows/buildpack-integration-test.yml@v1.8.0 with: @@ -41,4 +44,5 @@ jobs: cloudevent-builder-source: 'tests/conformance' cloudevent-builder-target: 'write_cloud_event_declarative' prerun: 'tests/conformance/prerun.sh ${{ github.sha }}' - builder-runtime: 'python310' \ No newline at end of file + builder-runtime: 'python310' + start-delay: 5 \ No newline at end of file From 0ced0d214731e97d0c59abd937ab9a15498f26c3 Mon Sep 17 00:00:00 2001 From: Matthew Robertson Date: Fri, 2 Dec 2022 14:38:01 -0800 Subject: [PATCH 2/6] test: add 3.11 to unit and conformance tests (#209) In preparation for adding the python311 runtime to GCF, we should start testing this version. --- .github/workflows/conformance.yml | 4 ++-- .github/workflows/unit.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 09752c94..9ae8a51e 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -5,7 +5,7 @@ jobs: runs-on: ubuntu-18.04 strategy: matrix: - python-version: ['3.8', '3.9', '3.10'] + python-version: ['3.8', '3.9', '3.10', '3.11'] steps: - name: Checkout code uses: actions/checkout@v2 @@ -75,4 +75,4 @@ jobs: functionType: 'http' useBuildpacks: false validateConcurrency: true - cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" \ No newline at end of file + cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index ea147493..39bd1be1 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -4,7 +4,7 @@ jobs: test: strategy: matrix: - python: ['3.6', '3.7', '3.8', '3.9', '3.10'] + python: ['3.7', '3.8', '3.9', '3.10', '3.11'] platform: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.platform }} steps: From f013ab4e4c00acae827ad85e6e2ac5698859605f Mon Sep 17 00:00:00 2001 From: Annie Fu <16651409+anniefu@users.noreply.github.com> Date: Tue, 6 Dec 2022 16:10:06 -0800 Subject: [PATCH 3/6] fix: remove DRY_RUN env var and --dry-run flag (#210) Free the DRY_RUN env var name to be used for other purposes by function authors. The original intent of the DRY_RUN was to be used at build time for GCF to validate function syntax without starting the server, but this was never implemented. For local testing purposes, simply starting the functions framework server is a better method. --- README.md | 1 - src/functions_framework/_cli.py | 10 ++-------- tests/test_cli.py | 12 ------------ 3 files changed, 2 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 192a9b55..9dffc60c 100644 --- a/README.md +++ b/README.md @@ -322,7 +322,6 @@ You can configure the Functions Framework using command-line flags or environmen | `--signature-type` | `FUNCTION_SIGNATURE_TYPE` | The signature used when writing your function. Controls unmarshalling rules and determines which arguments are used to invoke your function. Default: `http`; accepted values: `http`, `event` or `cloudevent` | | `--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) | | `--debug` | `DEBUG` | A flag that allows to run functions-framework to run in debug mode, including live reloading. Default: `False` | -| `--dry-run` | `DRY_RUN` | A flag that allows for testing the function build from the configuration without creating a server. Default: `False` | ## Enable Google Cloud Function Events diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 663ea50f..5b54a1cd 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -32,12 +32,6 @@ @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") @click.option("--port", envvar="PORT", type=click.INT, default=8080) @click.option("--debug", envvar="DEBUG", is_flag=True) -@click.option("--dry-run", envvar="DRY_RUN", is_flag=True) -def _cli(target, source, signature_type, host, port, debug, dry_run): +def _cli(target, source, signature_type, host, port, debug): app = create_app(target, source, signature_type) - if dry_run: - click.echo("Function: {}".format(target)) - click.echo("URL: http://{}:{}/".format(host, port)) - click.echo("Dry run successful, shutting down.") - else: - create_server(app, debug).run(host, port) + create_server(app, debug).run(host, port) diff --git a/tests/test_cli.py b/tests/test_cli.py index aa4a901e..7613b649 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -69,18 +69,6 @@ def test_cli_no_arguments(): [pretend.call("foo", None, "event")], [pretend.call("0.0.0.0", 8080)], ), - ( - ["--target", "foo", "--dry-run"], - {}, - [pretend.call("foo", None, "http")], - [], - ), - ( - [], - {"FUNCTION_TARGET": "foo", "DRY_RUN": "True"}, - [pretend.call("foo", None, "http")], - [], - ), ( ["--target", "foo", "--host", "127.0.0.1"], {}, From aa59a6b6839820946dc72ceea7fd97b3dfd839c2 Mon Sep 17 00:00:00 2001 From: Pratiksha Kap <107430880+kappratiksha@users.noreply.github.com> Date: Mon, 12 Dec 2022 03:35:09 -0800 Subject: [PATCH 4/6] feat: Support strongly typed functions signature (#208) --- src/functions_framework/__init__.py | 69 ++++++++- src/functions_framework/_cli.py | 2 +- src/functions_framework/_function_registry.py | 13 ++ src/functions_framework/_typed_event.py | 105 +++++++++++++ .../typed_events/mismatch_types.py | 43 ++++++ .../typed_events/missing_from_dict.py | 55 +++++++ .../typed_events/missing_parameter.py | 23 +++ .../typed_events/missing_to_dict.py | 55 +++++++ .../typed_events/missing_type.py | 26 ++++ .../typed_events/typed_event.py | 141 ++++++++++++++++++ tests/test_typed_event_functions.py | 123 +++++++++++++++ 11 files changed, 653 insertions(+), 2 deletions(-) create mode 100644 src/functions_framework/_typed_event.py create mode 100644 tests/test_functions/typed_events/mismatch_types.py create mode 100644 tests/test_functions/typed_events/missing_from_dict.py create mode 100644 tests/test_functions/typed_events/missing_parameter.py create mode 100644 tests/test_functions/typed_events/missing_to_dict.py create mode 100644 tests/test_functions/typed_events/missing_type.py create mode 100644 tests/test_functions/typed_events/typed_event.py create mode 100644 tests/test_typed_event_functions.py diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index fa638505..c2a52d74 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -13,12 +13,17 @@ # limitations under the License. import functools +import inspect import io import json import logging import os.path import pathlib import sys +import types + +from inspect import signature +from typing import Type import cloudevents.exceptions as cloud_exceptions import flask @@ -26,7 +31,7 @@ from cloudevents.http import from_http, is_binary -from functions_framework import _function_registry, event_conversion +from functions_framework import _function_registry, _typed_event, event_conversion from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, @@ -67,6 +72,33 @@ def wrapper(*args, **kwargs): return wrapper +def typed(*args): + def _typed(func): + _typed_event.register_typed_event(input_type, func) + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + + return wrapper + + # no input type provided as a parameter, we need to use reflection + # e.g function declaration: + # @typed + # def myfunc(x:input_type) + if len(args) == 1 and isinstance(args[0], types.FunctionType): + input_type = None + return _typed(args[0]) + + # input type provided as a parameter to the decorator + # e.g. function declaration + # @typed(input_type) + # def myfunc(x) + else: + input_type = args[0] + return _typed + + def http(func): """Decorator that registers http as user function signature type.""" _function_registry.REGISTRY_MAP[ @@ -106,6 +138,26 @@ def _run_cloud_event(function, request): function(event) +def _typed_event_func_wrapper(function, request, inputType: Type): + def view_func(path): + try: + data = request.get_json() + input = inputType.from_dict(data) + response = function(input) + if response is None: + return "", 200 + if response.__class__.__module__ == "builtins": + return response + _typed_event._validate_return_type(response) + return json.dumps(response.to_dict()) + except Exception as e: + raise FunctionsFrameworkException( + "Function execution failed with the error" + ) from e + + return view_func + + def _cloud_event_view_func_wrapper(function, request): def view_func(path): ce_exception = None @@ -216,6 +268,21 @@ def _configure_app(app, function, signature_type): app.view_functions[signature_type] = _cloud_event_view_func_wrapper( function, flask.request ) + elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE: + app.url_map.add( + werkzeug.routing.Rule( + "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] + ) + ) + app.url_map.add( + werkzeug.routing.Rule( + "/", endpoint=signature_type, methods=["POST"] + ) + ) + input_type = _function_registry.get_func_input_type(function.__name__) + app.view_functions[signature_type] = _typed_event_func_wrapper( + function, flask.request, input_type + ) else: raise FunctionsFrameworkException( "Invalid signature type: {signature_type}".format( diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 5b54a1cd..773dd4cd 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -26,7 +26,7 @@ @click.option( "--signature-type", envvar="FUNCTION_SIGNATURE_TYPE", - type=click.Choice(["http", "event", "cloudevent"]), + type=click.Choice(["http", "event", "cloudevent", "typed"]), default="http", ) @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index fdcf383f..f266ee82 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -16,6 +16,9 @@ import sys import types +from re import T +from typing import Type + from functions_framework.exceptions import ( InvalidConfigurationException, InvalidTargetTypeException, @@ -28,11 +31,16 @@ HTTP_SIGNATURE_TYPE = "http" CLOUDEVENT_SIGNATURE_TYPE = "cloudevent" BACKGROUNDEVENT_SIGNATURE_TYPE = "event" +TYPED_SIGNATURE_TYPE = "typed" # REGISTRY_MAP stores the registered functions. # Keys are user function names, values are user function signature types. REGISTRY_MAP = {} +# INPUT_TYPE_MAP stores the input type of the typed functions. +# Keys are the user function name, values are the type of the function input +INPUT_TYPE_MAP = {} + def get_user_function(source, source_module, target): """Returns user function, raises exception for invalid function.""" @@ -120,3 +128,8 @@ def get_func_signature_type(func_name: str, signature_type: str) -> str: if os.environ.get("ENTRY_POINT"): os.environ["FUNCTION_TRIGGER_TYPE"] = sig_type return sig_type + + +def get_func_input_type(func_name: str) -> Type: + registered_type = INPUT_TYPE_MAP[func_name] if func_name in INPUT_TYPE_MAP else "" + return registered_type diff --git a/src/functions_framework/_typed_event.py b/src/functions_framework/_typed_event.py new file mode 100644 index 00000000..40e715ae --- /dev/null +++ b/src/functions_framework/_typed_event.py @@ -0,0 +1,105 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import inspect + +from inspect import signature + +from functions_framework import _function_registry +from functions_framework.exceptions import FunctionsFrameworkException + +"""Registers user function in the REGISTRY_MAP and the INPUT_TYPE_MAP. +Also performs some validity checks for the input type of the function + +Args: + decorator_type: The type provided by the @typed(input_type) decorator + func: User function +""" + + +def register_typed_event(decorator_type, func): + try: + 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) + except IndexError: + raise FunctionsFrameworkException( + "Function signature is missing an input parameter." + "The function should be defined as 'def your_fn(in: inputType)'" + ) + except Exception as e: + raise FunctionsFrameworkException( + "Functions using the @typed decorator must provide " + "the type of the input parameter by specifying @typed(inputType) and/or using python " + "type annotations 'def your_fn(in: inputType)'" + ) + + _function_registry.INPUT_TYPE_MAP[func.__name__] = input_type + _function_registry.REGISTRY_MAP[ + func.__name__ + ] = _function_registry.TYPED_SIGNATURE_TYPE + + +""" Checks whether the response type of the typed function has a to_dict method""" + + +def _validate_return_type(response): + if not (hasattr(response, "to_dict") and callable(getattr(response, "to_dict"))): + raise AttributeError( + "The type {response} does not have the required method called " + " 'to_dict'.".format(response=type(response)) + ) + + +"""Selects the input type for the typed function provided through the @typed(input_type) +decorator or through the parameter annotation in the user function +""" + + +def _select_input_type(decorator_type, annotation_type): + if decorator_type == None and annotation_type is inspect._empty: + raise TypeError( + "The function defined does not contain Type of the input object." + ) + + if ( + decorator_type != None + and annotation_type is not inspect._empty + and decorator_type != annotation_type + ): + raise TypeError( + "The object type provided via 'typed' decorator: '{decorator_type}'" + "is different than the one specified by the function parameter's type annotation : '{annotation_type}'.".format( + decorator_type=decorator_type, annotation_type=annotation_type + ) + ) + + if decorator_type == None: + return annotation_type + return decorator_type + + +"""Checks for the from_dict method implementation in the input type class""" + + +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) + ) diff --git a/tests/test_functions/typed_events/mismatch_types.py b/tests/test_functions/typed_events/mismatch_types.py new file mode 100644 index 00000000..0f238d9c --- /dev/null +++ b/tests/test_functions/typed_events/mismatch_types.py @@ -0,0 +1,43 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" + +import flask + +import functions_framework + + +class TestType1: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + +class TestType2: + name: str + + def __init__(self, name: str) -> None: + self.name = name + + +@functions_framework.typed(TestType2) +def function_typed_mismatch_types(test_type: TestType1): + valid_event = test_type.name == "john" and test_type.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return test_type diff --git a/tests/test_functions/typed_events/missing_from_dict.py b/tests/test_functions/typed_events/missing_from_dict.py new file mode 100644 index 00000000..73a2cf93 --- /dev/null +++ b/tests/test_functions/typed_events/missing_from_dict.py @@ -0,0 +1,55 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +from typing import Any, TypeVar + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +class TestTypeMissingFromDict: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["age"] = from_int(self.age) + return result + + +@functions_framework.typed(TestTypeMissingFromDict) +def function_typed_missing_from_dict(test_type: TestTypeMissingFromDict): + valid_event = test_type.name == "john" and test_type.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return test_type diff --git a/tests/test_functions/typed_events/missing_parameter.py b/tests/test_functions/typed_events/missing_parameter.py new file mode 100644 index 00000000..64681d8e --- /dev/null +++ b/tests/test_functions/typed_events/missing_parameter.py @@ -0,0 +1,23 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +import flask + +import functions_framework + + +@functions_framework.typed +def function_typed_missing_type_information(): + print("hello") diff --git a/tests/test_functions/typed_events/missing_to_dict.py b/tests/test_functions/typed_events/missing_to_dict.py new file mode 100644 index 00000000..76c95344 --- /dev/null +++ b/tests/test_functions/typed_events/missing_to_dict.py @@ -0,0 +1,55 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +from typing import Any, TypeVar + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +class TestTypeMissingToDict: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + @staticmethod + def from_dict(obj: dict) -> "TestTypeMissingToDict": + name = from_str(obj.get("name")) + age = from_int(obj.get("age")) + return TestTypeMissingToDict(name, age) + + +@functions_framework.typed(TestTypeMissingToDict) +def function_typed_missing_to_dict(testType: TestTypeMissingToDict): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType diff --git a/tests/test_functions/typed_events/missing_type.py b/tests/test_functions/typed_events/missing_type.py new file mode 100644 index 00000000..1f35c0d6 --- /dev/null +++ b/tests/test_functions/typed_events/missing_type.py @@ -0,0 +1,26 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +import flask + +import functions_framework + + +@functions_framework.typed +def function_typed_missing_type_information(testType): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType diff --git a/tests/test_functions/typed_events/typed_event.py b/tests/test_functions/typed_events/typed_event.py new file mode 100644 index 00000000..ac00d2fe --- /dev/null +++ b/tests/test_functions/typed_events/typed_event.py @@ -0,0 +1,141 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Function used to test handling functions using typed decorators.""" +from typing import Any, Type, TypeVar, cast + +import flask + +import functions_framework + +T = TypeVar("T") + + +def from_str(x: Any) -> str: + assert isinstance(x, str) + return x + + +def from_int(x: Any) -> int: + assert isinstance(x, int) and not isinstance(x, bool) + return x + + +def to_class(c: Type[T], x: Any) -> dict: + assert isinstance(x, c) + return cast(Any, x).to_dict() + + +class TestType: + name: str + age: int + + def __init__(self, name: str, age: int) -> None: + self.name = name + self.age = age + + @staticmethod + def from_dict(obj: dict) -> "TestType": + name = from_str(obj.get("name")) + age = from_int(obj.get("age")) + return TestType(name, age) + + def to_dict(self) -> dict: + result: dict = {} + result["name"] = from_str(self.name) + result["age"] = from_int(self.age) + return result + + +class SampleType: + country: str + population: int + + def __init__(self, country: str, population: int) -> None: + self.country = country + self.population = population + + @staticmethod + def from_dict(obj: dict) -> "SampleType": + country = from_str(obj.get("country")) + population = from_int(obj.get("population")) + return SampleType(country, population) + + def to_dict(self) -> dict: + result: dict = {} + result["country"] = from_str(self.country) + result["population"] = from_int(self.population) + return result + + +class FaultyType: + country: str + population: int + + def __init__(self, country: str, population: int) -> None: + self.country = country + self.population = population + + @staticmethod + def from_dict(obj: dict) -> "SampleType": + country = from_str(obj.get("country")) + population = from_int(obj.get("population")) + return SampleType(country, population / 0) + + +@functions_framework.typed(TestType) +def function_typed(testType: TestType): + valid_event = testType.name == "john" and testType.age == 10 + if not valid_event: + raise Exception("Received invalid input") + return testType + + +@functions_framework.typed +def function_typed_reflect(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + return testType + + +@functions_framework.typed +def function_typed_no_return(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + + +@functions_framework.typed +def function_typed_string_return(testType: TestType): + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + return "Hello " + testType.name + + +@functions_framework.typed(TestType) +def function_typed_different_types(testType: TestType) -> SampleType: + valid_event = testType.name == "jane" and testType.age == 20 + if not valid_event: + raise Exception("Received invalid input") + sampleType = SampleType("Monaco", 40000) + return sampleType + + +@functions_framework.typed +def function_typed_faulty_from_dict(input: FaultyType): + valid_event = input.country == "Monaco" and input.population == 40000 + if not valid_event: + raise Exception("Received invalid input") diff --git a/tests/test_typed_event_functions.py b/tests/test_typed_event_functions.py new file mode 100644 index 00000000..3b8d5da1 --- /dev/null +++ b/tests/test_typed_event_functions.py @@ -0,0 +1,123 @@ +# Copyright 2022 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import pathlib + +import pytest + +from functions_framework import create_app +from functions_framework.exceptions import FunctionsFrameworkException + +TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" + +# Python 3.5: ModuleNotFoundError does not exist +try: + _ModuleNotFoundError = ModuleNotFoundError +except: + _ModuleNotFoundError = ImportError + + +@pytest.fixture +def typed_decorator_client(function_name): + source = TEST_FUNCTIONS_DIR / "typed_events" / "typed_event.py" + target = function_name + return create_app(target, source).test_client() + + +@pytest.fixture +def typed_decorator_missing_to_dict(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_to_dict.py" + target = "function_typed_missing_to_dict" + return create_app(target, source).test_client() + + +@pytest.mark.parametrize("function_name", ["function_typed"]) +def test_typed_decorator(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "john", "age": 10}) + assert resp.status_code == 200 + assert resp.data == b'{"name": "john", "age": 10}' + + +@pytest.mark.parametrize("function_name", ["function_typed"]) +def test_typed_malformed_json(typed_decorator_client): + resp = typed_decorator_client.post("/", data="abc", content_type="application/json") + assert resp.status_code == 500 + + +@pytest.mark.parametrize("function_name", ["function_typed_faulty_from_dict"]) +def test_typed_faulty_from_dict(typed_decorator_client): + resp = typed_decorator_client.post( + "/", json={"country": "Monaco", "population": 40000} + ) + assert resp.status_code == 500 + + +@pytest.mark.parametrize("function_name", ["function_typed_reflect"]) +def test_typed_reflect_decorator(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b'{"name": "jane", "age": 20}' + + +@pytest.mark.parametrize("function_name", ["function_typed_different_types"]) +def test_typed_different_types(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b'{"country": "Monaco", "population": 40000}' + + +@pytest.mark.parametrize("function_name", ["function_typed_no_return"]) +def test_typed_no_return(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b"" + + +@pytest.mark.parametrize("function_name", ["function_typed_string_return"]) +def test_typed_string_return(typed_decorator_client): + resp = typed_decorator_client.post("/", json={"name": "jane", "age": 20}) + assert resp.status_code == 200 + assert resp.data == b"Hello jane" + + +def test_missing_from_dict_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_from_dict.py" + target = "function_typed_missing_from_dict" + with pytest.raises(FunctionsFrameworkException) as excinfo: + create_app(target, source).test_client() + + +def test_mismatch_types_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "mismatch_types.py" + target = "function_typed_mismatch_types" + with pytest.raises(FunctionsFrameworkException) as excinfo: + create_app(target, source).test_client() + + +def test_missing_type_information_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_type.py" + target = "function_typed_missing_type_information" + with pytest.raises(FunctionsFrameworkException): + create_app(target, source).test_client() + + +def test_missing_parameter_typed_decorator(): + source = TEST_FUNCTIONS_DIR / "typed_events" / "missing_parameter.py" + target = "function_typed_missing_parameter" + with pytest.raises(FunctionsFrameworkException): + create_app(target, source).test_client() + + +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 From ccc674796634991afa24f4fc37ded0266d8677e2 Mon Sep 17 00:00:00 2001 From: Pratiksha Kap <107430880+kappratiksha@users.noreply.github.com> Date: Thu, 15 Dec 2022 20:53:49 -0800 Subject: [PATCH 5/6] chore: Add conformance test for typed decorator (#212) --- .github/workflows/conformance.yml | 9 +++++++++ tests/conformance/main.py | 17 +++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/.github/workflows/conformance.yml b/.github/workflows/conformance.yml index 9ae8a51e..03f27450 100644 --- a/.github/workflows/conformance.yml +++ b/.github/workflows/conformance.yml @@ -76,3 +76,12 @@ jobs: useBuildpacks: false validateConcurrency: true cmd: "'functions-framework --source tests/conformance/main.py --target write_http_declarative_concurrent'" + + - name: Run Typed tests declarative + uses: GoogleCloudPlatform/functions-framework-conformance/action@v1.6.0 + with: + version: 'v1.6.0' + functionType: 'http' + useBuildpacks: false + validateMapping: false + cmd: "'functions-framework --source tests/conformance/main.py --target write_typed_event_declarative'" diff --git a/tests/conformance/main.py b/tests/conformance/main.py index 67926ff6..057edaa7 100644 --- a/tests/conformance/main.py +++ b/tests/conformance/main.py @@ -8,6 +8,17 @@ filename = "function_output.json" +class ConformanceType: + json_request: str + + def __init__(self, json_request: str) -> None: + self.json_request = json_request + + @staticmethod + def from_dict(obj: dict) -> "ConformanceType": + return ConformanceType(json.dumps(obj)) + + def _write_output(content): with open(filename, "w") as f: f.write(content) @@ -53,3 +64,9 @@ def write_cloud_event_declarative(cloud_event): def write_http_declarative_concurrent(request): time.sleep(1) return "OK", 200 + + +@functions_framework.typed(ConformanceType) +def write_typed_event_declarative(x): + _write_output(x.json_request) + return "OK" From 9904e9be1b365f1f5df1d57b1577a91e178797bf Mon Sep 17 00:00:00 2001 From: "release-please[bot]" <55107282+release-please[bot]@users.noreply.github.com> Date: Wed, 21 Dec 2022 07:36:37 -0800 Subject: [PATCH 6/6] chore(master): release 3.3.0 (#211) Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com> --- CHANGELOG.md | 12 ++++++++++++ setup.py | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba019d5..26e83453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.3.0](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.2.1...v3.3.0) (2022-12-16) + + +### Features + +* Support strongly typed functions signature ([#208](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/208)) ([aa59a6b](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/aa59a6b6839820946dc72ceea7fd97b3dfd839c2)) + + +### Bug Fixes + +* remove DRY_RUN env var and --dry-run flag ([#210](https://github.com/GoogleCloudPlatform/functions-framework-python/issues/210)) ([f013ab4](https://github.com/GoogleCloudPlatform/functions-framework-python/commit/f013ab4e4c00acae827ad85e6e2ac5698859605f)) + ## [3.2.1](https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v3.2.0...v3.2.1) (2022-11-09) diff --git a/setup.py b/setup.py index 8be93e27..46b4bdfe 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="3.2.1", + version="3.3.0", description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.", long_description=long_description, long_description_content_type="text/markdown",