Skip to content

Commit aa59a6b

Browse files
authored
feat: Support strongly typed functions signature (#208)
1 parent f013ab4 commit aa59a6b

11 files changed

+653
-2
lines changed

src/functions_framework/__init__.py

+68-1
Original file line numberDiff line numberDiff line change
@@ -13,20 +13,25 @@
1313
# limitations under the License.
1414

1515
import functools
16+
import inspect
1617
import io
1718
import json
1819
import logging
1920
import os.path
2021
import pathlib
2122
import sys
23+
import types
24+
25+
from inspect import signature
26+
from typing import Type
2227

2328
import cloudevents.exceptions as cloud_exceptions
2429
import flask
2530
import werkzeug
2631

2732
from cloudevents.http import from_http, is_binary
2833

29-
from functions_framework import _function_registry, event_conversion
34+
from functions_framework import _function_registry, _typed_event, event_conversion
3035
from functions_framework.background_event import BackgroundEvent
3136
from functions_framework.exceptions import (
3237
EventConversionException,
@@ -67,6 +72,33 @@ def wrapper(*args, **kwargs):
6772
return wrapper
6873

6974

75+
def typed(*args):
76+
def _typed(func):
77+
_typed_event.register_typed_event(input_type, func)
78+
79+
@functools.wraps(func)
80+
def wrapper(*args, **kwargs):
81+
return func(*args, **kwargs)
82+
83+
return wrapper
84+
85+
# no input type provided as a parameter, we need to use reflection
86+
# e.g function declaration:
87+
# @typed
88+
# def myfunc(x:input_type)
89+
if len(args) == 1 and isinstance(args[0], types.FunctionType):
90+
input_type = None
91+
return _typed(args[0])
92+
93+
# input type provided as a parameter to the decorator
94+
# e.g. function declaration
95+
# @typed(input_type)
96+
# def myfunc(x)
97+
else:
98+
input_type = args[0]
99+
return _typed
100+
101+
70102
def http(func):
71103
"""Decorator that registers http as user function signature type."""
72104
_function_registry.REGISTRY_MAP[
@@ -106,6 +138,26 @@ def _run_cloud_event(function, request):
106138
function(event)
107139

108140

141+
def _typed_event_func_wrapper(function, request, inputType: Type):
142+
def view_func(path):
143+
try:
144+
data = request.get_json()
145+
input = inputType.from_dict(data)
146+
response = function(input)
147+
if response is None:
148+
return "", 200
149+
if response.__class__.__module__ == "builtins":
150+
return response
151+
_typed_event._validate_return_type(response)
152+
return json.dumps(response.to_dict())
153+
except Exception as e:
154+
raise FunctionsFrameworkException(
155+
"Function execution failed with the error"
156+
) from e
157+
158+
return view_func
159+
160+
109161
def _cloud_event_view_func_wrapper(function, request):
110162
def view_func(path):
111163
ce_exception = None
@@ -216,6 +268,21 @@ def _configure_app(app, function, signature_type):
216268
app.view_functions[signature_type] = _cloud_event_view_func_wrapper(
217269
function, flask.request
218270
)
271+
elif signature_type == _function_registry.TYPED_SIGNATURE_TYPE:
272+
app.url_map.add(
273+
werkzeug.routing.Rule(
274+
"/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"]
275+
)
276+
)
277+
app.url_map.add(
278+
werkzeug.routing.Rule(
279+
"/<path:path>", endpoint=signature_type, methods=["POST"]
280+
)
281+
)
282+
input_type = _function_registry.get_func_input_type(function.__name__)
283+
app.view_functions[signature_type] = _typed_event_func_wrapper(
284+
function, flask.request, input_type
285+
)
219286
else:
220287
raise FunctionsFrameworkException(
221288
"Invalid signature type: {signature_type}".format(

src/functions_framework/_cli.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
@click.option(
2727
"--signature-type",
2828
envvar="FUNCTION_SIGNATURE_TYPE",
29-
type=click.Choice(["http", "event", "cloudevent"]),
29+
type=click.Choice(["http", "event", "cloudevent", "typed"]),
3030
default="http",
3131
)
3232
@click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0")

src/functions_framework/_function_registry.py

+13
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
import sys
1717
import types
1818

19+
from re import T
20+
from typing import Type
21+
1922
from functions_framework.exceptions import (
2023
InvalidConfigurationException,
2124
InvalidTargetTypeException,
@@ -28,11 +31,16 @@
2831
HTTP_SIGNATURE_TYPE = "http"
2932
CLOUDEVENT_SIGNATURE_TYPE = "cloudevent"
3033
BACKGROUNDEVENT_SIGNATURE_TYPE = "event"
34+
TYPED_SIGNATURE_TYPE = "typed"
3135

3236
# REGISTRY_MAP stores the registered functions.
3337
# Keys are user function names, values are user function signature types.
3438
REGISTRY_MAP = {}
3539

40+
# INPUT_TYPE_MAP stores the input type of the typed functions.
41+
# Keys are the user function name, values are the type of the function input
42+
INPUT_TYPE_MAP = {}
43+
3644

3745
def get_user_function(source, source_module, target):
3846
"""Returns user function, raises exception for invalid function."""
@@ -120,3 +128,8 @@ def get_func_signature_type(func_name: str, signature_type: str) -> str:
120128
if os.environ.get("ENTRY_POINT"):
121129
os.environ["FUNCTION_TRIGGER_TYPE"] = sig_type
122130
return sig_type
131+
132+
133+
def get_func_input_type(func_name: str) -> Type:
134+
registered_type = INPUT_TYPE_MAP[func_name] if func_name in INPUT_TYPE_MAP else ""
135+
return registered_type
+105
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
16+
import inspect
17+
18+
from inspect import signature
19+
20+
from functions_framework import _function_registry
21+
from functions_framework.exceptions import FunctionsFrameworkException
22+
23+
"""Registers user function in the REGISTRY_MAP and the INPUT_TYPE_MAP.
24+
Also performs some validity checks for the input type of the function
25+
26+
Args:
27+
decorator_type: The type provided by the @typed(input_type) decorator
28+
func: User function
29+
"""
30+
31+
32+
def register_typed_event(decorator_type, func):
33+
try:
34+
sig = signature(func)
35+
annotation_type = list(sig.parameters.values())[0].annotation
36+
input_type = _select_input_type(decorator_type, annotation_type)
37+
_validate_input_type(input_type)
38+
except IndexError:
39+
raise FunctionsFrameworkException(
40+
"Function signature is missing an input parameter."
41+
"The function should be defined as 'def your_fn(in: inputType)'"
42+
)
43+
except Exception as e:
44+
raise FunctionsFrameworkException(
45+
"Functions using the @typed decorator must provide "
46+
"the type of the input parameter by specifying @typed(inputType) and/or using python "
47+
"type annotations 'def your_fn(in: inputType)'"
48+
)
49+
50+
_function_registry.INPUT_TYPE_MAP[func.__name__] = input_type
51+
_function_registry.REGISTRY_MAP[
52+
func.__name__
53+
] = _function_registry.TYPED_SIGNATURE_TYPE
54+
55+
56+
""" Checks whether the response type of the typed function has a to_dict method"""
57+
58+
59+
def _validate_return_type(response):
60+
if not (hasattr(response, "to_dict") and callable(getattr(response, "to_dict"))):
61+
raise AttributeError(
62+
"The type {response} does not have the required method called "
63+
" 'to_dict'.".format(response=type(response))
64+
)
65+
66+
67+
"""Selects the input type for the typed function provided through the @typed(input_type)
68+
decorator or through the parameter annotation in the user function
69+
"""
70+
71+
72+
def _select_input_type(decorator_type, annotation_type):
73+
if decorator_type == None and annotation_type is inspect._empty:
74+
raise TypeError(
75+
"The function defined does not contain Type of the input object."
76+
)
77+
78+
if (
79+
decorator_type != None
80+
and annotation_type is not inspect._empty
81+
and decorator_type != annotation_type
82+
):
83+
raise TypeError(
84+
"The object type provided via 'typed' decorator: '{decorator_type}'"
85+
"is different than the one specified by the function parameter's type annotation : '{annotation_type}'.".format(
86+
decorator_type=decorator_type, annotation_type=annotation_type
87+
)
88+
)
89+
90+
if decorator_type == None:
91+
return annotation_type
92+
return decorator_type
93+
94+
95+
"""Checks for the from_dict method implementation in the input type class"""
96+
97+
98+
def _validate_input_type(input_type):
99+
if not (
100+
hasattr(input_type, "from_dict") and callable(getattr(input_type, "from_dict"))
101+
):
102+
raise AttributeError(
103+
"The type {decorator_type} does not have the required method called "
104+
" 'from_dict'.".format(decorator_type=input_type)
105+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Function used to test handling functions using typed decorators."""
16+
17+
import flask
18+
19+
import functions_framework
20+
21+
22+
class TestType1:
23+
name: str
24+
age: int
25+
26+
def __init__(self, name: str, age: int) -> None:
27+
self.name = name
28+
self.age = age
29+
30+
31+
class TestType2:
32+
name: str
33+
34+
def __init__(self, name: str) -> None:
35+
self.name = name
36+
37+
38+
@functions_framework.typed(TestType2)
39+
def function_typed_mismatch_types(test_type: TestType1):
40+
valid_event = test_type.name == "john" and test_type.age == 10
41+
if not valid_event:
42+
raise Exception("Received invalid input")
43+
return test_type
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Function used to test handling functions using typed decorators."""
16+
from typing import Any, TypeVar
17+
18+
import flask
19+
20+
import functions_framework
21+
22+
T = TypeVar("T")
23+
24+
25+
def from_str(x: Any) -> str:
26+
assert isinstance(x, str)
27+
return x
28+
29+
30+
def from_int(x: Any) -> int:
31+
assert isinstance(x, int) and not isinstance(x, bool)
32+
return x
33+
34+
35+
class TestTypeMissingFromDict:
36+
name: str
37+
age: int
38+
39+
def __init__(self, name: str, age: int) -> None:
40+
self.name = name
41+
self.age = age
42+
43+
def to_dict(self) -> dict:
44+
result: dict = {}
45+
result["name"] = from_str(self.name)
46+
result["age"] = from_int(self.age)
47+
return result
48+
49+
50+
@functions_framework.typed(TestTypeMissingFromDict)
51+
def function_typed_missing_from_dict(test_type: TestTypeMissingFromDict):
52+
valid_event = test_type.name == "john" and test_type.age == 10
53+
if not valid_event:
54+
raise Exception("Received invalid input")
55+
return test_type
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# Copyright 2022 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Function used to test handling functions using typed decorators."""
16+
import flask
17+
18+
import functions_framework
19+
20+
21+
@functions_framework.typed
22+
def function_typed_missing_type_information():
23+
print("hello")

0 commit comments

Comments
 (0)