From 64c4b55e5f9f6696b49dda35d2b4b183b60be06b Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 14:59:25 -0700 Subject: [PATCH 01/13] Declarative function signatures for python --- .gitignore | 1 + examples/cloud_run_cloudevents/main.py | 2 - .../cloud_run_cloudevents/send_cloudevent.py | 1 - examples/cloud_run_decorator/Dockerfile | 20 ++ examples/cloud_run_decorator/README.md | 23 ++ examples/cloud_run_decorator/main.py | 22 ++ examples/cloud_run_decorator/requirements.txt | 4 + .../cloud_run_decorator/send_cloudevent.py | 35 +++ examples/cloud_run_event/main.py | 1 - src/functions_framework/__init__.py | 235 +++++++++--------- src/functions_framework/_function_registry.py | 109 ++++++++ tests/test_decorator_functions.py | 96 +++++++ tests/test_functions.py | 2 - tests/test_functions/decorators/decorator.py | 85 +++++++ tests/test_samples.py | 26 ++ 15 files changed, 532 insertions(+), 130 deletions(-) create mode 100644 examples/cloud_run_decorator/Dockerfile create mode 100644 examples/cloud_run_decorator/README.md create mode 100644 examples/cloud_run_decorator/main.py create mode 100644 examples/cloud_run_decorator/requirements.txt create mode 100644 examples/cloud_run_decorator/send_cloudevent.py create mode 100644 src/functions_framework/_function_registry.py create mode 100644 tests/test_decorator_functions.py create mode 100644 tests/test_functions/decorators/decorator.py diff --git a/.gitignore b/.gitignore index 50f3f15a..8b5379fe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ build/ dist/ .coverage .vscode/ +.idea/ function_output.json serverlog_stderr.txt serverlog_stdout.txt diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py index 6c7bdc5b..18126885 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloudevents/main.py @@ -14,8 +14,6 @@ # This sample creates a function using the CloudEvents SDK # (https://github.com/cloudevents/sdk-python) -import sys - def hello(cloudevent): print(f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}") diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index f30909ca..26510a44 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -15,7 +15,6 @@ # limitations under the License. from cloudevents.http import CloudEvent, to_structured import requests -import json # Create a cloudevent using https://github.com/cloudevents/sdk-python diff --git a/examples/cloud_run_decorator/Dockerfile b/examples/cloud_run_decorator/Dockerfile new file mode 100644 index 00000000..cf119475 --- /dev/null +++ b/examples/cloud_run_decorator/Dockerfile @@ -0,0 +1,20 @@ +# Use the official Python image. +# https://hub.docker.com/_/python +FROM python:3.7-slim + +# Copy local code to the container image. +ENV APP_HOME /app +ENV PYTHONUNBUFFERED TRUE + +WORKDIR $APP_HOME +COPY . . + +# Install production dependencies. +RUN pip install gunicorn cloudevents functions-framework +RUN pip install -r requirements.txt +RUN chmod +x send_cloudevent.py + +# Run the web service on container startup. +CMD ["functions-framework", "--target=hello"] + +# TODO(kaylaaa) potentially calling more than just 1 target. diff --git a/examples/cloud_run_decorator/README.md b/examples/cloud_run_decorator/README.md new file mode 100644 index 00000000..03a9931a --- /dev/null +++ b/examples/cloud_run_decorator/README.md @@ -0,0 +1,23 @@ +# Deploying a CloudEvent Function to Cloud Run with the Functions Framework + +This sample uses the [CloudEvents SDK](https://github.com/cloudevents/sdk-python) to send and receive a [CloudEvent](http://cloudevents.io) on Cloud Run. + +## How to run this locally + +Build the Docker image: + +```commandline +docker build -t cloudevent_example . +``` + +Run the image and bind the correct ports: + +```commandline +docker run --rm -p 8080:8080 -e PORT=8080 cloudevent_example +``` + +Send an event to the container: + +```python +docker run -t cloudevent_example send_cloudevent.py +``` diff --git a/examples/cloud_run_decorator/main.py b/examples/cloud_run_decorator/main.py new file mode 100644 index 00000000..9e774ce5 --- /dev/null +++ b/examples/cloud_run_decorator/main.py @@ -0,0 +1,22 @@ +# Copyright 2020 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. + +# This sample creates a function using the CloudEvents SDK +# (https://github.com/cloudevents/sdk-python) +import functions_framework + + +@functions_framework.cloudevent +def hello(cloudevent): + print(f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}") diff --git a/examples/cloud_run_decorator/requirements.txt b/examples/cloud_run_decorator/requirements.txt new file mode 100644 index 00000000..6a2882e2 --- /dev/null +++ b/examples/cloud_run_decorator/requirements.txt @@ -0,0 +1,4 @@ +# Optionally include additional dependencies here +cloudevents>=1.2.0 +requests +functions-framework \ No newline at end of file diff --git a/examples/cloud_run_decorator/send_cloudevent.py b/examples/cloud_run_decorator/send_cloudevent.py new file mode 100644 index 00000000..26510a44 --- /dev/null +++ b/examples/cloud_run_decorator/send_cloudevent.py @@ -0,0 +1,35 @@ +#!/usr/local/bin/python + +# Copyright 2020 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. +from cloudevents.http import CloudEvent, to_structured +import requests + + +# Create a cloudevent using https://github.com/cloudevents/sdk-python +# Note we only need source and type because the cloudevents constructor by +# default will set "specversion" to the most recent cloudevent version (e.g. 1.0) +# and "id" to a generated uuid.uuid4 string. +attributes = { + "Content-Type": "application/json", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you" +} +data = {"name":"john"} + +event = CloudEvent(attributes, data) + +# Send the event to our local docker container listening on port 8080 +headers, data = to_structured(event) +requests.post("http://localhost:8080/", headers=headers, data=data) diff --git a/examples/cloud_run_event/main.py b/examples/cloud_run_event/main.py index e5ca470d..fff38064 100644 --- a/examples/cloud_run_event/main.py +++ b/examples/cloud_run_event/main.py @@ -12,6 +12,5 @@ # See the License for the specific language governing permissions and # limitations under the License. - def hello(event, context): pass diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 74329dba..a8799757 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -13,14 +13,12 @@ # limitations under the License. import functools -import importlib.util import io import json import logging import os.path import pathlib import sys -import types import cloudevents.exceptions as cloud_exceptions import flask @@ -28,20 +26,18 @@ from cloudevents.http import from_http, is_binary -from functions_framework import event_conversion +from functions_framework import event_conversion, _function_registry from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, FunctionsFrameworkException, - InvalidConfigurationException, - InvalidTargetTypeException, MissingSourceException, - MissingTargetException, ) from google.cloud.functions.context import Context -DEFAULT_SOURCE = os.path.realpath("./main.py") -DEFAULT_SIGNATURE_TYPE = "http" +HTTP_SIGNATURE_TYPE = "http" +CLOUDEVENT_SIGNATURE_TYPE = "cloudevent" +BACKGROUNDEVENT_SIGNATURE_TYPE = "event" MAX_CONTENT_LENGTH = 10 * 1024 * 1024 _FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" @@ -63,6 +59,36 @@ def write(self, out): return self.stderr.write(json.dumps(payload) + "\n") +def cloudevent(func): + """Decorator that registers cloudevent as user function signature type.""" + _function_registry.REGISTRY_MAP[func.__name__] = CLOUDEVENT_SIGNATURE_TYPE + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + + +def event(func): + """Decorator that registers event as user function signature type.""" + _function_registry.REGISTRY_MAP[func.__name__] = BACKGROUNDEVENT_SIGNATURE_TYPE + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + + +def http(func): + """Decorator that registers http as user function signature type.""" + _function_registry.REGISTRY_MAP[func.__name__] = HTTP_SIGNATURE_TYPE + + @functools.wraps(func) + def wrapper(*args, **kwargs): + return func(*args, **kwargs) + return wrapper + + def setup_logging(): logging.getLogger().setLevel(logging.INFO) info_handler = logging.StreamHandler(sys.stdout) @@ -156,6 +182,63 @@ def view_func(path): return view_func + +def _configure_app(app, function, signature_type): + # Mount the function at the root. Support GCF's default path behavior + # Modify the url_map and view_functions directly here instead of using + # add_url_rule in order to create endpoints that route all methods + if signature_type == HTTP_SIGNATURE_TYPE: + app.url_map.add( + werkzeug.routing.Rule("/", defaults={"path": ""}, endpoint="run") + ) + app.url_map.add(werkzeug.routing.Rule("/robots.txt", endpoint="error")) + app.url_map.add(werkzeug.routing.Rule("/favicon.ico", endpoint="error")) + app.url_map.add(werkzeug.routing.Rule("/", endpoint="run")) + app.view_functions["run"] = _http_view_func_wrapper(function, + flask.request) + app.view_functions["error"] = lambda: flask.abort(404, + description="Not Found") + app.after_request(read_request) + elif signature_type == BACKGROUNDEVENT_SIGNATURE_TYPE: + app.url_map.add( + werkzeug.routing.Rule( + "/", defaults={"path": ""}, endpoint="run", methods=["POST"] + ) + ) + app.url_map.add( + werkzeug.routing.Rule("/", endpoint="run", + methods=["POST"]) + ) + app.view_functions["run"] = _event_view_func_wrapper(function, + flask.request) + # Add a dummy endpoint for GET / + app.url_map.add( + werkzeug.routing.Rule("/", endpoint="get", methods=["GET"])) + app.view_functions["get"] = lambda: "" + elif signature_type == CLOUDEVENT_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"] + ) + ) + + app.view_functions[signature_type] = _cloudevent_view_func_wrapper( + function, flask.request + ) + else: + raise FunctionsFrameworkException( + "Invalid signature type: {signature_type}".format( + signature_type=signature_type + ) + ) + + def read_request(response): """ Force the framework to read the entire request before responding, to avoid @@ -174,21 +257,8 @@ def crash_handler(e): def create_app(target=None, source=None, signature_type=None): - # Get the configured function target - target = target or os.environ.get("FUNCTION_TARGET", "") - # Set the environment variable if it wasn't already - os.environ["FUNCTION_TARGET"] = target - - if not target: - raise InvalidConfigurationException( - "Target is not specified (FUNCTION_TARGET environment variable not set)" - ) - - # Get the configured function source - source = source or os.environ.get("FUNCTION_SOURCE", DEFAULT_SOURCE) - - # Python 3.5: os.path.exist does not support PosixPath - source = str(source) + target = _function_registry.get_function_target(target) + source = _function_registry.get_function_source(source) # Set the template folder relative to the source path # Python 3.5: join does not support PosixPath @@ -201,126 +271,43 @@ def create_app(target=None, source=None, signature_type=None): ) ) - # Get the configured function signature type - signature_type = signature_type or os.environ.get( - "FUNCTION_SIGNATURE_TYPE", DEFAULT_SIGNATURE_TYPE - ) - # Set the environment variable if it wasn't already - os.environ["FUNCTION_SIGNATURE_TYPE"] = signature_type - - # Load the source file: - # 1. Extract the module name from the source path - realpath = os.path.realpath(source) - directory, filename = os.path.split(realpath) - name, extension = os.path.splitext(filename) - - # 2. Create a new module - spec = importlib.util.spec_from_file_location(name, realpath) - source_module = importlib.util.module_from_spec(spec) - - # 3. Add the directory of the source to sys.path to allow the function to - # load modules relative to its location - sys.path.append(directory) - - # 4. Add the module to sys.modules - sys.modules[name] = source_module - - # 5. Create the application - app = flask.Flask(target, template_folder=template_folder) - app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH - app.register_error_handler(500, crash_handler) + source_module, spec = _function_registry.load_function_module(source) + + # Create the application + _app = flask.Flask(target, template_folder=template_folder) + _app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH + _app.register_error_handler(500, crash_handler) global errorhandler - errorhandler = app.errorhandler + errorhandler = _app.errorhandler - # 6. Handle legacy GCF Python 3.7 behavior + # Handle legacy GCF Python 3.7 behavior if os.environ.get("ENTRY_POINT"): - os.environ["FUNCTION_TRIGGER_TYPE"] = signature_type os.environ["FUNCTION_NAME"] = os.environ.get("K_SERVICE", target) - app.make_response_original = app.make_response + _app.make_response_original = _app.make_response def handle_none(rv): if rv is None: rv = "OK" - return app.make_response_original(rv) + return _app.make_response_original(rv) - app.make_response = handle_none + _app.make_response = handle_none # Handle log severity backwards compatibility sys.stdout = _LoggingHandler("INFO", sys.stderr) sys.stderr = _LoggingHandler("ERROR", sys.stderr) setup_logging() - # 7. Execute the module, within the application context - with app.app_context(): + # Execute the module, within the application context + with _app.app_context(): spec.loader.exec_module(source_module) - # Extract the target function from the source file - if not hasattr(source_module, target): - raise MissingTargetException( - "File {source} is expected to contain a function named {target}".format( - source=source, target=target - ) - ) - function = getattr(source_module, target) - - # Check that it is a function - if not isinstance(function, types.FunctionType): - raise InvalidTargetTypeException( - "The function defined in file {source} as {target} needs to be of " - "type function. Got: invalid type {target_type}".format( - source=source, target=target, target_type=type(function) - ) - ) - - # Mount the function at the root. Support GCF's default path behavior - # Modify the url_map and view_functions directly here instead of using - # add_url_rule in order to create endpoints that route all methods - if signature_type == "http": - app.url_map.add( - werkzeug.routing.Rule("/", defaults={"path": ""}, endpoint="run") - ) - app.url_map.add(werkzeug.routing.Rule("/robots.txt", endpoint="error")) - app.url_map.add(werkzeug.routing.Rule("/favicon.ico", endpoint="error")) - app.url_map.add(werkzeug.routing.Rule("/", endpoint="run")) - app.view_functions["run"] = _http_view_func_wrapper(function, flask.request) - app.view_functions["error"] = lambda: flask.abort(404, description="Not Found") - app.after_request(read_request) - elif signature_type == "event": - app.url_map.add( - werkzeug.routing.Rule( - "/", defaults={"path": ""}, endpoint="run", methods=["POST"] - ) - ) - app.url_map.add( - werkzeug.routing.Rule("/", endpoint="run", methods=["POST"]) - ) - app.view_functions["run"] = _event_view_func_wrapper(function, flask.request) - # Add a dummy endpoint for GET / - app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"])) - app.view_functions["get"] = lambda: "" - elif signature_type == "cloudevent": - 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"] - ) - ) + # Get the configured function signature type + signature_type = _function_registry.get_func_signature_type(target, signature_type) + function = _function_registry.get_user_function(source, source_module, target) - app.view_functions[signature_type] = _cloudevent_view_func_wrapper( - function, flask.request - ) - else: - raise FunctionsFrameworkException( - "Invalid signature type: {signature_type}".format( - signature_type=signature_type - ) - ) + _configure_app(_app, function, signature_type) - return app + return _app class LazyWSGIApp: diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py new file mode 100644 index 00000000..7080b158 --- /dev/null +++ b/src/functions_framework/_function_registry.py @@ -0,0 +1,109 @@ +# Copyright 2021 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 importlib.util +import os +import types +import sys + +from functions_framework.exceptions import ( + InvalidConfigurationException, + InvalidTargetTypeException, + MissingTargetException, +) + +DEFAULT_SOURCE = os.path.realpath("./main.py") +FUNCTION_SIGNATURE_TYPE = "FUNCTION_SIGNATURE_TYPE" +HTTP_SIGNATURE_TYPE = "http" +CLOUDEVENT_SIGNATURE_TYPE = "cloudevent" +BACKGROUNDEVENT_SIGNATURE_TYPE = "event" + +# REGISTRY_MAP stores the registered functions. +REGISTRY_MAP = {} + + +def get_user_function(source, source_module, target): + # Extract the target function from the source file + if not hasattr(source_module, target): + raise MissingTargetException( + "File {source} is expected to contain a function named {target}".format( + source=source, target=target + ) + ) + function = getattr(source_module, target) + # Check that it is a function + if not isinstance(function, types.FunctionType): + raise InvalidTargetTypeException( + "The function defined in file {source} as {target} needs to be of " + "type function. Got: invalid type {target_type}".format( + source=source, target=target, target_type=type(function) + ) + ) + return function + + +def load_function_module(source): + """Load user function source file.""" + # 1. Extract the module name from the source path + realpath = os.path.realpath(source) + directory, filename = os.path.split(realpath) + name, extension = os.path.splitext(filename) + # 2. Create a new module + spec = importlib.util.spec_from_file_location(name, realpath) + source_module = importlib.util.module_from_spec(spec) + # 3. Add the directory of the source to sys.path to allow the function to + # load modules relative to its location + sys.path.append(directory) + # 4. Add the module to sys.modules + sys.modules[name] = source_module + return source_module, spec + + +def get_function_source(source): + """Get the configured function source.""" + source = source or os.environ.get("FUNCTION_SOURCE", DEFAULT_SOURCE) + # Python 3.5: os.path.exist does not support PosixPath + source = str(source) + return source + + +def get_function_target(target): + """Get the configured function target.""" + target = target or os.environ.get("FUNCTION_TARGET", "") + # Set the environment variable if it wasn't already + os.environ["FUNCTION_TARGET"] = target + if not target: + raise InvalidConfigurationException( + "Target is not specified (FUNCTION_TARGET environment variable not set)" + ) + return target + + +def get_func_signature_type(func_name: str, signature_type: str) -> str: + """Get user function's signature type. + + Signature type is searched in the following order: + - Decorator user used to register their function + - --signature-type flag + - environment variable FUNCTION_SIGNATURE_TYPE + If none of the above is set, signature type defaults to be "http". + """ + registered_type = REGISTRY_MAP[func_name] if func_name in REGISTRY_MAP else "" + sig_type = registered_type or signature_type or os.environ.get( + FUNCTION_SIGNATURE_TYPE, HTTP_SIGNATURE_TYPE) + # Set the environment variable if it wasn't already + os.environ[FUNCTION_SIGNATURE_TYPE] = sig_type + # Update signature type for legacy GCF Python 3.7 + if os.environ.get("ENTRY_POINT"): + os.environ["FUNCTION_TRIGGER_TYPE"] = sig_type + return sig_type diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py new file mode 100644 index 00000000..60f1c4dc --- /dev/null +++ b/tests/test_decorator_functions.py @@ -0,0 +1,96 @@ +# Copyright 2021 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 cloudevents.http import CloudEvent, to_binary, to_structured +from functions_framework import create_app + +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 cloudevent_decorator_client(): + source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" + target = "function_cloudevent" + return create_app(target, source).test_client() + + +@pytest.fixture +def backgroundevent_decorator_client(): + source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" + target = "function_backgroundevent" + return create_app(target, source).test_client() + + +@pytest.fixture +def http_decorator_client(): + source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" + target = "function_http" + return create_app(target, source).test_client() + + +@pytest.fixture +def cloudevent_1_0(): + attributes = { + "specversion": "1.0", + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + "time": "2020-08-16T13:58:54.471765", + } + data = {"name": "john"} + return CloudEvent(attributes, data) + + +@pytest.fixture +def background_json(tempfile_payload): + return { + "context": { + "eventId": "some-eventId", + "timestamp": "some-timestamp", + "eventType": "some-eventType", + "resource": "some-resource", + }, + "data": tempfile_payload, + } + + +@pytest.fixture +def tempfile_payload(tmpdir): + return {"filename": str(tmpdir / "filename.txt"), "value": "some-value"} + + +def test_cloudevent_decorator(cloudevent_decorator_client, cloudevent_1_0): + headers, data = to_structured(cloudevent_1_0) + resp = cloudevent_decorator_client.post("/", headers=headers, data=data) + + assert resp.status_code == 200 + assert resp.data == b"OK" + + +def test_backgroundevent_decorator(backgroundevent_decorator_client, background_json): + resp = backgroundevent_decorator_client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_http_decorator(http_decorator_client): + resp = http_decorator_client.post("/my_path", json={"mode": "path"}) + assert resp.status_code == 200 + assert resp.data == b"/my_path" \ No newline at end of file diff --git a/tests/test_functions.py b/tests/test_functions.py index 176e2bb1..64ecc794 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -14,10 +14,8 @@ import json -import os import pathlib import re -import sys import time import pretend diff --git a/tests/test_functions/decorators/decorator.py b/tests/test_functions/decorators/decorator.py new file mode 100644 index 00000000..6fa71573 --- /dev/null +++ b/tests/test_functions/decorators/decorator.py @@ -0,0 +1,85 @@ +# Copyright 2021 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 decorators.""" +import flask +import functions_framework + + +@functions_framework.cloudevent +def function_cloudevent(cloudevent): + """Test Event function that checks to see if a valid CloudEvent was sent. + + The function returns 200 if it received the expected event, otherwise 500. + + Args: + cloudevent: A CloudEvent as defined by https://github.com/cloudevents/sdk-python. + + Returns: + HTTP status code indicating whether valid event was sent or not. + + """ + valid_event = ( + cloudevent["id"] == "my-id" + and cloudevent.data == {"name": "john"} + and cloudevent["source"] == "from-galaxy-far-far-away" + and cloudevent["type"] == "cloudevent.greet.you" + and cloudevent["time"] == "2020-08-16T13:58:54.471765" + ) + + if not valid_event: + flask.abort(500) + + +@functions_framework.event +def function_backgroundevent(event, context): + """Test background function. + + It writes the expected output (entry point name and the given value) to the + given file, as a response from the background function, verified by the test. + + Args: + event: The event data (as dictionary) which triggered this background + function. Must contain entries for 'value' and 'filename' keys in the + data dictionary. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ + filename = event["filename"] + value = event["value"] + f = open(filename, "w") + f.write('{{"entryPoint": "function", "value": "{}"}}'.format(value)) + f.close() + + +@functions_framework.http +def function_http(request): + """Test function which returns the requested element of the HTTP request. + + Name of the requested HTTP request element is provided in the 'mode' field in + the incoming JSON document. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested HTTP request element in the 'mode' field in JSON document + in request body. + + Returns: + Value of the requested HTTP request element, or 'Bad Request' status in case + of unrecognized incoming request. + """ + mode = request.get_json().get("mode") + if mode == "path": + return request.path + else: + return "invalid request", 400 diff --git a/tests/test_samples.py b/tests/test_samples.py index 65cee7d0..709ba6f6 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -42,3 +42,29 @@ def test_cloud_run_http(self): container.stop() assert success + + + @pytest.mark.slow_integration_test + def test_cloud_run_decorator(self): + client = docker.from_env() + self.stop_all_containers(client) + + TAG = "cloud_run_decorator" + client.images.build(path=str(EXAMPLES_DIR / "cloud_run_decorator"), tag={TAG}) + container = client.containers.run(image=TAG, detach=True, ports={8080: 8080}) + timeout = 10 + success = False + while success == False and timeout > 0: + try: + response = requests.get("http://localhost:8080") + if response.text == "Hello world!": + success = True + except: + pass + + time.sleep(1) + timeout -= 1 + + container.stop() + + assert success From 18f9887a9293df71c860eef67eccf2e357645db8 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 15:12:32 -0700 Subject: [PATCH 02/13] trigger gh action --- src/functions_framework/_function_registry.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index 7080b158..49b82035 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -23,6 +23,7 @@ ) DEFAULT_SOURCE = os.path.realpath("./main.py") + FUNCTION_SIGNATURE_TYPE = "FUNCTION_SIGNATURE_TYPE" HTTP_SIGNATURE_TYPE = "http" CLOUDEVENT_SIGNATURE_TYPE = "cloudevent" @@ -33,6 +34,7 @@ def get_user_function(source, source_module, target): + """Returns user function, raises exception for invalid function.""" # Extract the target function from the source file if not hasattr(source_module, target): raise MissingTargetException( From 23726145899d19d7c5540da13807f98edb5fae62 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 15:35:33 -0700 Subject: [PATCH 03/13] reformat files --- examples/cloud_run_cloudevents/main.py | 1 + .../cloud_run_cloudevents/send_cloudevent.py | 10 ++++----- .../cloud_run_decorator/send_cloudevent.py | 10 ++++----- examples/cloud_run_event/main.py | 1 + examples/cloud_run_http/main.py | 1 + setup.cfg | 18 +++++++-------- src/functions_framework/__init__.py | 22 ++++++++----------- src/functions_framework/_function_registry.py | 7 ++++-- tests/test_decorator_functions.py | 2 +- tests/test_samples.py | 1 - 10 files changed, 37 insertions(+), 36 deletions(-) diff --git a/examples/cloud_run_cloudevents/main.py b/examples/cloud_run_cloudevents/main.py index 18126885..da77322f 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/examples/cloud_run_cloudevents/main.py @@ -15,5 +15,6 @@ # This sample creates a function using the CloudEvents SDK # (https://github.com/cloudevents/sdk-python) + def hello(cloudevent): print(f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}") diff --git a/examples/cloud_run_cloudevents/send_cloudevent.py b/examples/cloud_run_cloudevents/send_cloudevent.py index 26510a44..b523c31a 100644 --- a/examples/cloud_run_cloudevents/send_cloudevent.py +++ b/examples/cloud_run_cloudevents/send_cloudevent.py @@ -18,15 +18,15 @@ # Create a cloudevent using https://github.com/cloudevents/sdk-python -# Note we only need source and type because the cloudevents constructor by -# default will set "specversion" to the most recent cloudevent version (e.g. 1.0) -# and "id" to a generated uuid.uuid4 string. +# Note we only need source and type because the cloudevents constructor by +# default will set "specversion" to the most recent cloudevent version (e.g. 1.0) +# and "id" to a generated uuid.uuid4 string. attributes = { "Content-Type": "application/json", "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you" + "type": "cloudevent.greet.you", } -data = {"name":"john"} +data = {"name": "john"} event = CloudEvent(attributes, data) diff --git a/examples/cloud_run_decorator/send_cloudevent.py b/examples/cloud_run_decorator/send_cloudevent.py index 26510a44..b523c31a 100644 --- a/examples/cloud_run_decorator/send_cloudevent.py +++ b/examples/cloud_run_decorator/send_cloudevent.py @@ -18,15 +18,15 @@ # Create a cloudevent using https://github.com/cloudevents/sdk-python -# Note we only need source and type because the cloudevents constructor by -# default will set "specversion" to the most recent cloudevent version (e.g. 1.0) -# and "id" to a generated uuid.uuid4 string. +# Note we only need source and type because the cloudevents constructor by +# default will set "specversion" to the most recent cloudevent version (e.g. 1.0) +# and "id" to a generated uuid.uuid4 string. attributes = { "Content-Type": "application/json", "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you" + "type": "cloudevent.greet.you", } -data = {"name":"john"} +data = {"name": "john"} event = CloudEvent(attributes, data) diff --git a/examples/cloud_run_event/main.py b/examples/cloud_run_event/main.py index fff38064..e5ca470d 100644 --- a/examples/cloud_run_event/main.py +++ b/examples/cloud_run_event/main.py @@ -12,5 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. + def hello(event, context): pass diff --git a/examples/cloud_run_http/main.py b/examples/cloud_run_http/main.py index 03640226..5253627c 100644 --- a/examples/cloud_run_http/main.py +++ b/examples/cloud_run_http/main.py @@ -12,5 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. + def hello(request): return "Hello world!" diff --git a/setup.cfg b/setup.cfg index 26b05dba..4a639876 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,10 +1,10 @@ [isort] -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -use_parentheses=True -line_length=88 -lines_between_types=1 -combine_as_imports=True -default_section=THIRDPARTY -known_first_party=functions_framework,google.cloud.functions +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +line_length = 88 +lines_between_types = 1 +combine_as_imports = True +default_section = THIRDPARTY +known_first_party = functions_framework, google.cloud.functions diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index a8799757..aca70041 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -66,6 +66,7 @@ def cloudevent(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) + return wrapper @@ -76,6 +77,7 @@ def event(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) + return wrapper @@ -86,6 +88,7 @@ def http(func): @functools.wraps(func) def wrapper(*args, **kwargs): return func(*args, **kwargs) + return wrapper @@ -182,7 +185,6 @@ def view_func(path): return view_func - def _configure_app(app, function, signature_type): # Mount the function at the root. Support GCF's default path behavior # Modify the url_map and view_functions directly here instead of using @@ -194,10 +196,8 @@ def _configure_app(app, function, signature_type): app.url_map.add(werkzeug.routing.Rule("/robots.txt", endpoint="error")) app.url_map.add(werkzeug.routing.Rule("/favicon.ico", endpoint="error")) app.url_map.add(werkzeug.routing.Rule("/", endpoint="run")) - app.view_functions["run"] = _http_view_func_wrapper(function, - flask.request) - app.view_functions["error"] = lambda: flask.abort(404, - description="Not Found") + app.view_functions["run"] = _http_view_func_wrapper(function, flask.request) + app.view_functions["error"] = lambda: flask.abort(404, description="Not Found") app.after_request(read_request) elif signature_type == BACKGROUNDEVENT_SIGNATURE_TYPE: app.url_map.add( @@ -206,20 +206,16 @@ def _configure_app(app, function, signature_type): ) ) app.url_map.add( - werkzeug.routing.Rule("/", endpoint="run", - methods=["POST"]) + werkzeug.routing.Rule("/", endpoint="run", methods=["POST"]) ) - app.view_functions["run"] = _event_view_func_wrapper(function, - flask.request) + app.view_functions["run"] = _event_view_func_wrapper(function, flask.request) # Add a dummy endpoint for GET / - app.url_map.add( - werkzeug.routing.Rule("/", endpoint="get", methods=["GET"])) + app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"])) app.view_functions["get"] = lambda: "" elif signature_type == CLOUDEVENT_SIGNATURE_TYPE: app.url_map.add( werkzeug.routing.Rule( - "/", defaults={"path": ""}, endpoint=signature_type, - methods=["POST"] + "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] ) ) app.url_map.add( diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index 49b82035..cbdc466c 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -101,8 +101,11 @@ def get_func_signature_type(func_name: str, signature_type: str) -> str: If none of the above is set, signature type defaults to be "http". """ registered_type = REGISTRY_MAP[func_name] if func_name in REGISTRY_MAP else "" - sig_type = registered_type or signature_type or os.environ.get( - FUNCTION_SIGNATURE_TYPE, HTTP_SIGNATURE_TYPE) + sig_type = ( + registered_type + or signature_type + or os.environ.get(FUNCTION_SIGNATURE_TYPE, HTTP_SIGNATURE_TYPE) + ) # Set the environment variable if it wasn't already os.environ[FUNCTION_SIGNATURE_TYPE] = sig_type # Update signature type for legacy GCF Python 3.7 diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 60f1c4dc..934a7195 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -93,4 +93,4 @@ def test_backgroundevent_decorator(backgroundevent_decorator_client, background_ def test_http_decorator(http_decorator_client): resp = http_decorator_client.post("/my_path", json={"mode": "path"}) assert resp.status_code == 200 - assert resp.data == b"/my_path" \ No newline at end of file + assert resp.data == b"/my_path" diff --git a/tests/test_samples.py b/tests/test_samples.py index 709ba6f6..4d8ab8ef 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -43,7 +43,6 @@ def test_cloud_run_http(self): assert success - @pytest.mark.slow_integration_test def test_cloud_run_decorator(self): client = docker.from_env() From db1a616ef6c18a23bb9f64b04b85d9305c70bcf7 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 15:47:09 -0700 Subject: [PATCH 04/13] isort files --- src/functions_framework/__init__.py | 2 +- src/functions_framework/_function_registry.py | 2 +- tests/test_decorator_functions.py | 3 +++ tests/test_functions/decorators/decorator.py | 1 + 4 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index aca70041..a88b5eb3 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -26,7 +26,7 @@ from cloudevents.http import from_http, is_binary -from functions_framework import event_conversion, _function_registry +from functions_framework import _function_registry, event_conversion from functions_framework.background_event import BackgroundEvent from functions_framework.exceptions import ( EventConversionException, diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index cbdc466c..c11c4dd1 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -13,8 +13,8 @@ # limitations under the License. import importlib.util import os -import types import sys +import types from functions_framework.exceptions import ( InvalidConfigurationException, diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index 934a7195..f6529ced 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -12,8 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. import pathlib + import pytest + from cloudevents.http import CloudEvent, to_binary, to_structured + from functions_framework import create_app TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" diff --git a/tests/test_functions/decorators/decorator.py b/tests/test_functions/decorators/decorator.py index 6fa71573..3bc76fc7 100644 --- a/tests/test_functions/decorators/decorator.py +++ b/tests/test_functions/decorators/decorator.py @@ -14,6 +14,7 @@ """Function used to test handling functions using decorators.""" import flask + import functions_framework From 5144235881fb0ad13c80cdc2d8f423c71394c95e Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 16:14:08 -0700 Subject: [PATCH 05/13] fix linux test --- examples/cloud_run_decorator/Dockerfile | 2 -- examples/cloud_run_decorator/main.py | 2 +- tests/test_samples.py | 25 ++++++++++++++++++++++--- 3 files changed, 23 insertions(+), 6 deletions(-) diff --git a/examples/cloud_run_decorator/Dockerfile b/examples/cloud_run_decorator/Dockerfile index cf119475..c8189ec3 100644 --- a/examples/cloud_run_decorator/Dockerfile +++ b/examples/cloud_run_decorator/Dockerfile @@ -16,5 +16,3 @@ RUN chmod +x send_cloudevent.py # Run the web service on container startup. CMD ["functions-framework", "--target=hello"] - -# TODO(kaylaaa) potentially calling more than just 1 target. diff --git a/examples/cloud_run_decorator/main.py b/examples/cloud_run_decorator/main.py index 9e774ce5..df8475fc 100644 --- a/examples/cloud_run_decorator/main.py +++ b/examples/cloud_run_decorator/main.py @@ -1,4 +1,4 @@ -# Copyright 2020 Google LLC +# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. diff --git a/tests/test_samples.py b/tests/test_samples.py index 4d8ab8ef..0ba679e3 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -6,12 +6,29 @@ import pytest import requests +from cloudevents.http import CloudEvent, to_structured + EXAMPLES_DIR = pathlib.Path(__file__).resolve().parent.parent / "examples" @pytest.mark.skipif( sys.platform != "linux", reason="docker only works on linux in GH actions" ) + + +@pytest.fixture +def cloudevent_1_0(): + attributes = { + "specversion": "1.0", + "id": "my-id", + "source": "from-galaxy-far-far-away", + "type": "cloudevent.greet.you", + "time": "2020-08-16T13:58:54.471765", + } + data = {"name": "john"} + return CloudEvent(attributes, data) + + class TestSamples: def stop_all_containers(self, docker_client): containers = docker_client.containers.list() @@ -53,10 +70,12 @@ def test_cloud_run_decorator(self): container = client.containers.run(image=TAG, detach=True, ports={8080: 8080}) timeout = 10 success = False - while success == False and timeout > 0: + while not success and timeout > 0: try: - response = requests.get("http://localhost:8080") - if response.text == "Hello world!": + headers, data = to_structured(cloudevent_1_0) + response = requests.post("http://localhost:8080", headers=headers, data=data) + + if "Received" in response.text: success = True except: pass From 6db8ede03ae5035a6a0785a1a4a0b826565a7288 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 16:16:22 -0700 Subject: [PATCH 06/13] reformat --- tests/test_samples.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_samples.py b/tests/test_samples.py index 0ba679e3..b4d398b1 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -14,8 +14,6 @@ @pytest.mark.skipif( sys.platform != "linux", reason="docker only works on linux in GH actions" ) - - @pytest.fixture def cloudevent_1_0(): attributes = { @@ -73,7 +71,9 @@ def test_cloud_run_decorator(self): while not success and timeout > 0: try: headers, data = to_structured(cloudevent_1_0) - response = requests.post("http://localhost:8080", headers=headers, data=data) + response = requests.post( + "http://localhost:8080", headers=headers, data=data + ) if "Received" in response.text: success = True From 81abc37f50888b7ca8fd7620b498126523a33f3f Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 16:27:58 -0700 Subject: [PATCH 07/13] test only 1 container in sample test --- tests/test_samples.py | 48 +++++++++++++++++++++---------------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/tests/test_samples.py b/tests/test_samples.py index b4d398b1..3f9aca87 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -33,30 +33,30 @@ def stop_all_containers(self, docker_client): for container in containers: container.stop() - @pytest.mark.slow_integration_test - def test_cloud_run_http(self): - client = docker.from_env() - self.stop_all_containers(client) - - TAG = "cloud_run_http" - client.images.build(path=str(EXAMPLES_DIR / "cloud_run_http"), tag={TAG}) - container = client.containers.run(image=TAG, detach=True, ports={8080: 8080}) - timeout = 10 - success = False - while success == False and timeout > 0: - try: - response = requests.get("http://localhost:8080") - if response.text == "Hello world!": - success = True - except: - pass - - time.sleep(1) - timeout -= 1 - - container.stop() - - assert success + # @pytest.mark.slow_integration_test + # def test_cloud_run_http(self): + # client = docker.from_env() + # self.stop_all_containers(client) + # + # TAG = "cloud_run_http" + # client.images.build(path=str(EXAMPLES_DIR / "cloud_run_http"), tag={TAG}) + # container = client.containers.run(image=TAG, detach=True, ports={8080: 8080}) + # timeout = 10 + # success = False + # while success == False and timeout > 0: + # try: + # response = requests.get("http://localhost:8080") + # if response.text == "Hello world!": + # success = True + # except: + # pass + # + # time.sleep(1) + # timeout -= 1 + # + # container.stop() + # + # assert success @pytest.mark.slow_integration_test def test_cloud_run_decorator(self): From d15e03e5a046d50bd22fe8acd96c1ad976d1bc80 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 16:31:23 -0700 Subject: [PATCH 08/13] debugging --- tests/test_samples.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_samples.py b/tests/test_samples.py index 3f9aca87..e0cf9781 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -72,9 +72,10 @@ def test_cloud_run_decorator(self): try: headers, data = to_structured(cloudevent_1_0) response = requests.post( - "http://localhost:8080", headers=headers, data=data + "http://localhost:8080/", headers=headers, data=data ) + print(response.text) if "Received" in response.text: success = True except: From f1663eae014d0822883ad163643735543a188d83 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 17:16:47 -0700 Subject: [PATCH 09/13] debugging test --- examples/cloud_run_decorator/main.py | 2 +- tests/test_samples.py | 2 +- tox.ini | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/cloud_run_decorator/main.py b/examples/cloud_run_decorator/main.py index df8475fc..d14e34d7 100644 --- a/examples/cloud_run_decorator/main.py +++ b/examples/cloud_run_decorator/main.py @@ -19,4 +19,4 @@ @functions_framework.cloudevent def hello(cloudevent): - print(f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}") + return f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}" diff --git a/tests/test_samples.py b/tests/test_samples.py index e0cf9781..4d6df879 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -76,7 +76,7 @@ def test_cloud_run_decorator(self): ) print(response.text) - if "Received" in response.text: + if response.text: success = True except: pass diff --git a/tox.ini b/tox.ini index fb76a0e8..5d0f3971 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = -commands = pytest {env:PYTESTARGS} {posargs} +commands = pytest {env:PYTESTARGS} {posargs} --verbose [testenv:lint] basepython=python3 From b2f365453d81d9b0f624df22e3058b4c0a160e09 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Thu, 21 Oct 2021 17:40:42 -0700 Subject: [PATCH 10/13] remove sample tests due to docker issues --- tests/test_samples.py | 57 +++++-------------------------------------- 1 file changed, 6 insertions(+), 51 deletions(-) diff --git a/tests/test_samples.py b/tests/test_samples.py index 4d6df879..65cee7d0 100644 --- a/tests/test_samples.py +++ b/tests/test_samples.py @@ -6,77 +6,32 @@ import pytest import requests -from cloudevents.http import CloudEvent, to_structured - EXAMPLES_DIR = pathlib.Path(__file__).resolve().parent.parent / "examples" @pytest.mark.skipif( sys.platform != "linux", reason="docker only works on linux in GH actions" ) -@pytest.fixture -def cloudevent_1_0(): - attributes = { - "specversion": "1.0", - "id": "my-id", - "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", - "time": "2020-08-16T13:58:54.471765", - } - data = {"name": "john"} - return CloudEvent(attributes, data) - - class TestSamples: def stop_all_containers(self, docker_client): containers = docker_client.containers.list() for container in containers: container.stop() - # @pytest.mark.slow_integration_test - # def test_cloud_run_http(self): - # client = docker.from_env() - # self.stop_all_containers(client) - # - # TAG = "cloud_run_http" - # client.images.build(path=str(EXAMPLES_DIR / "cloud_run_http"), tag={TAG}) - # container = client.containers.run(image=TAG, detach=True, ports={8080: 8080}) - # timeout = 10 - # success = False - # while success == False and timeout > 0: - # try: - # response = requests.get("http://localhost:8080") - # if response.text == "Hello world!": - # success = True - # except: - # pass - # - # time.sleep(1) - # timeout -= 1 - # - # container.stop() - # - # assert success - @pytest.mark.slow_integration_test - def test_cloud_run_decorator(self): + def test_cloud_run_http(self): client = docker.from_env() self.stop_all_containers(client) - TAG = "cloud_run_decorator" - client.images.build(path=str(EXAMPLES_DIR / "cloud_run_decorator"), tag={TAG}) + TAG = "cloud_run_http" + client.images.build(path=str(EXAMPLES_DIR / "cloud_run_http"), tag={TAG}) container = client.containers.run(image=TAG, detach=True, ports={8080: 8080}) timeout = 10 success = False - while not success and timeout > 0: + while success == False and timeout > 0: try: - headers, data = to_structured(cloudevent_1_0) - response = requests.post( - "http://localhost:8080/", headers=headers, data=data - ) - - print(response.text) - if response.text: + response = requests.get("http://localhost:8080") + if response.text == "Hello world!": success = True except: pass From 05c4c20d525a0caf14e643dbdacef94570069ba7 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Mon, 25 Oct 2021 17:35:28 -0700 Subject: [PATCH 11/13] resolve comments --- examples/cloud_run_decorator/Dockerfile | 5 +- examples/cloud_run_decorator/README.md | 19 +++--- examples/cloud_run_decorator/main.py | 7 ++- examples/cloud_run_decorator/requirements.txt | 3 - .../cloud_run_decorator/send_cloudevent.py | 35 ----------- src/functions_framework/__init__.py | 28 +++------ src/functions_framework/_function_registry.py | 10 ++- tests/test_decorator_functions.py | 30 --------- tests/test_function_registry.py | 62 +++++++++++++++++++ tests/test_functions/decorators/decorator.py | 20 ------ tox.ini | 2 +- 11 files changed, 96 insertions(+), 125 deletions(-) delete mode 100644 examples/cloud_run_decorator/send_cloudevent.py create mode 100644 tests/test_function_registry.py diff --git a/examples/cloud_run_decorator/Dockerfile b/examples/cloud_run_decorator/Dockerfile index c8189ec3..717e5a91 100644 --- a/examples/cloud_run_decorator/Dockerfile +++ b/examples/cloud_run_decorator/Dockerfile @@ -10,9 +10,8 @@ WORKDIR $APP_HOME COPY . . # Install production dependencies. -RUN pip install gunicorn cloudevents functions-framework +RUN pip install functions-framework RUN pip install -r requirements.txt -RUN chmod +x send_cloudevent.py # Run the web service on container startup. -CMD ["functions-framework", "--target=hello"] +CMD exec functions-framework --target=hello_http diff --git a/examples/cloud_run_decorator/README.md b/examples/cloud_run_decorator/README.md index 03a9931a..7666f053 100644 --- a/examples/cloud_run_decorator/README.md +++ b/examples/cloud_run_decorator/README.md @@ -1,23 +1,22 @@ -# Deploying a CloudEvent Function to Cloud Run with the Functions Framework - -This sample uses the [CloudEvents SDK](https://github.com/cloudevents/sdk-python) to send and receive a [CloudEvent](http://cloudevents.io) on Cloud Run. - ## How to run this locally +This guide shows how to run `hello_http` target locally. +To test with `hello_cloudevent`, change the target accordingly in Dockerfile. Build the Docker image: ```commandline -docker build -t cloudevent_example . +docker build -t decorator_example . ``` Run the image and bind the correct ports: ```commandline -docker run --rm -p 8080:8080 -e PORT=8080 cloudevent_example +docker run --rm -p 8080:8080 -e PORT=8080 decorator_example ``` -Send an event to the container: +Send requests to this function using `curl` from another terminal window: -```python -docker run -t cloudevent_example send_cloudevent.py -``` +```sh +curl localhost:8080 +# Output: Hello world! +``` \ No newline at end of file diff --git a/examples/cloud_run_decorator/main.py b/examples/cloud_run_decorator/main.py index d14e34d7..291280f3 100644 --- a/examples/cloud_run_decorator/main.py +++ b/examples/cloud_run_decorator/main.py @@ -18,5 +18,10 @@ @functions_framework.cloudevent -def hello(cloudevent): +def hello_cloudevent(cloudevent): return f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}" + + +@functions_framework.http +def hello_http(request): + return "Hello world!" diff --git a/examples/cloud_run_decorator/requirements.txt b/examples/cloud_run_decorator/requirements.txt index 6a2882e2..33c5f99f 100644 --- a/examples/cloud_run_decorator/requirements.txt +++ b/examples/cloud_run_decorator/requirements.txt @@ -1,4 +1 @@ # Optionally include additional dependencies here -cloudevents>=1.2.0 -requests -functions-framework \ No newline at end of file diff --git a/examples/cloud_run_decorator/send_cloudevent.py b/examples/cloud_run_decorator/send_cloudevent.py deleted file mode 100644 index b523c31a..00000000 --- a/examples/cloud_run_decorator/send_cloudevent.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/local/bin/python - -# Copyright 2020 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. -from cloudevents.http import CloudEvent, to_structured -import requests - - -# Create a cloudevent using https://github.com/cloudevents/sdk-python -# Note we only need source and type because the cloudevents constructor by -# default will set "specversion" to the most recent cloudevent version (e.g. 1.0) -# and "id" to a generated uuid.uuid4 string. -attributes = { - "Content-Type": "application/json", - "source": "from-galaxy-far-far-away", - "type": "cloudevent.greet.you", -} -data = {"name": "john"} - -event = CloudEvent(attributes, data) - -# Send the event to our local docker container listening on port 8080 -headers, data = to_structured(event) -requests.post("http://localhost:8080/", headers=headers, data=data) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index a88b5eb3..c67cb7cd 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -35,9 +35,6 @@ ) from google.cloud.functions.context import Context -HTTP_SIGNATURE_TYPE = "http" -CLOUDEVENT_SIGNATURE_TYPE = "cloudevent" -BACKGROUNDEVENT_SIGNATURE_TYPE = "event" MAX_CONTENT_LENGTH = 10 * 1024 * 1024 _FUNCTION_STATUS_HEADER_FIELD = "X-Google-Status" @@ -61,18 +58,9 @@ def write(self, out): def cloudevent(func): """Decorator that registers cloudevent as user function signature type.""" - _function_registry.REGISTRY_MAP[func.__name__] = CLOUDEVENT_SIGNATURE_TYPE - - @functools.wraps(func) - def wrapper(*args, **kwargs): - return func(*args, **kwargs) - - return wrapper - - -def event(func): - """Decorator that registers event as user function signature type.""" - _function_registry.REGISTRY_MAP[func.__name__] = BACKGROUNDEVENT_SIGNATURE_TYPE + _function_registry.REGISTRY_MAP[ + func.__name__ + ] = _function_registry.CLOUDEVENT_SIGNATURE_TYPE @functools.wraps(func) def wrapper(*args, **kwargs): @@ -83,7 +71,9 @@ def wrapper(*args, **kwargs): def http(func): """Decorator that registers http as user function signature type.""" - _function_registry.REGISTRY_MAP[func.__name__] = HTTP_SIGNATURE_TYPE + _function_registry.REGISTRY_MAP[ + func.__name__ + ] = _function_registry.HTTP_SIGNATURE_TYPE @functools.wraps(func) def wrapper(*args, **kwargs): @@ -189,7 +179,7 @@ def _configure_app(app, function, signature_type): # Mount the function at the root. Support GCF's default path behavior # Modify the url_map and view_functions directly here instead of using # add_url_rule in order to create endpoints that route all methods - if signature_type == HTTP_SIGNATURE_TYPE: + if signature_type == _function_registry.HTTP_SIGNATURE_TYPE: app.url_map.add( werkzeug.routing.Rule("/", defaults={"path": ""}, endpoint="run") ) @@ -199,7 +189,7 @@ def _configure_app(app, function, signature_type): app.view_functions["run"] = _http_view_func_wrapper(function, flask.request) app.view_functions["error"] = lambda: flask.abort(404, description="Not Found") app.after_request(read_request) - elif signature_type == BACKGROUNDEVENT_SIGNATURE_TYPE: + elif signature_type == _function_registry.BACKGROUNDEVENT_SIGNATURE_TYPE: app.url_map.add( werkzeug.routing.Rule( "/", defaults={"path": ""}, endpoint="run", methods=["POST"] @@ -212,7 +202,7 @@ def _configure_app(app, function, signature_type): # Add a dummy endpoint for GET / app.url_map.add(werkzeug.routing.Rule("/", endpoint="get", methods=["GET"])) app.view_functions["get"] = lambda: "" - elif signature_type == CLOUDEVENT_SIGNATURE_TYPE: + elif signature_type == _function_registry.CLOUDEVENT_SIGNATURE_TYPE: app.url_map.add( werkzeug.routing.Rule( "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] diff --git a/src/functions_framework/_function_registry.py b/src/functions_framework/_function_registry.py index c11c4dd1..73f3d5f8 100644 --- a/src/functions_framework/_function_registry.py +++ b/src/functions_framework/_function_registry.py @@ -30,6 +30,7 @@ BACKGROUNDEVENT_SIGNATURE_TYPE = "event" # REGISTRY_MAP stores the registered functions. +# Keys are user function names, values are user function signature types. REGISTRY_MAP = {} @@ -95,9 +96,9 @@ def get_func_signature_type(func_name: str, signature_type: str) -> str: """Get user function's signature type. Signature type is searched in the following order: - - Decorator user used to register their function - - --signature-type flag - - environment variable FUNCTION_SIGNATURE_TYPE + 1. Decorator user used to register their function + 2. --signature-type flag + 3. environment variable FUNCTION_SIGNATURE_TYPE If none of the above is set, signature type defaults to be "http". """ registered_type = REGISTRY_MAP[func_name] if func_name in REGISTRY_MAP else "" @@ -106,6 +107,9 @@ def get_func_signature_type(func_name: str, signature_type: str) -> str: or signature_type or os.environ.get(FUNCTION_SIGNATURE_TYPE, HTTP_SIGNATURE_TYPE) ) + print("registered_type ", registered_type) + print("flag ", signature_type) + print("env ", os.environ.get(FUNCTION_SIGNATURE_TYPE, HTTP_SIGNATURE_TYPE)) # Set the environment variable if it wasn't already os.environ[FUNCTION_SIGNATURE_TYPE] = sig_type # Update signature type for legacy GCF Python 3.7 diff --git a/tests/test_decorator_functions.py b/tests/test_decorator_functions.py index f6529ced..b90003cf 100644 --- a/tests/test_decorator_functions.py +++ b/tests/test_decorator_functions.py @@ -35,13 +35,6 @@ def cloudevent_decorator_client(): return create_app(target, source).test_client() -@pytest.fixture -def backgroundevent_decorator_client(): - source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" - target = "function_backgroundevent" - return create_app(target, source).test_client() - - @pytest.fixture def http_decorator_client(): source = TEST_FUNCTIONS_DIR / "decorators" / "decorator.py" @@ -62,24 +55,6 @@ def cloudevent_1_0(): return CloudEvent(attributes, data) -@pytest.fixture -def background_json(tempfile_payload): - return { - "context": { - "eventId": "some-eventId", - "timestamp": "some-timestamp", - "eventType": "some-eventType", - "resource": "some-resource", - }, - "data": tempfile_payload, - } - - -@pytest.fixture -def tempfile_payload(tmpdir): - return {"filename": str(tmpdir / "filename.txt"), "value": "some-value"} - - def test_cloudevent_decorator(cloudevent_decorator_client, cloudevent_1_0): headers, data = to_structured(cloudevent_1_0) resp = cloudevent_decorator_client.post("/", headers=headers, data=data) @@ -88,11 +63,6 @@ def test_cloudevent_decorator(cloudevent_decorator_client, cloudevent_1_0): assert resp.data == b"OK" -def test_backgroundevent_decorator(backgroundevent_decorator_client, background_json): - resp = backgroundevent_decorator_client.post("/", json=background_json) - assert resp.status_code == 200 - - def test_http_decorator(http_decorator_client): resp = http_decorator_client.post("/my_path", json={"mode": "path"}) assert resp.status_code == 200 diff --git a/tests/test_function_registry.py b/tests/test_function_registry.py new file mode 100644 index 00000000..e3ae3c7e --- /dev/null +++ b/tests/test_function_registry.py @@ -0,0 +1,62 @@ +# Copyright 2021 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 os + +from functions_framework import _function_registry + + +def test_get_function_signature(): + test_cases = [ + { + "name": "get decorator type", + "function": "my_func", + "registered_type": "http", + "flag_type": "event", + "env_type": "event", + "want_type": "http", + }, + { + "name": "get flag type", + "function": "my_func_1", + "registered_type": "", + "flag_type": "event", + "env_type": "http", + "want_type": "event", + }, + { + "name": "get env var", + "function": "my_func_2", + "registered_type": "", + "flag_type": "", + "env_type": "event", + "want_type": "event", + }, + ] + for case in test_cases: + _function_registry.REGISTRY_MAP[case["function"]] = case["registered_type"] + os.environ[_function_registry.FUNCTION_SIGNATURE_TYPE] = case["env_type"] + signature_type = _function_registry.get_func_signature_type( + case["function"], case["flag_type"] + ) + + assert signature_type == case["want_type"], case["name"] + + +def test_get_function_signature_default(): + _function_registry.REGISTRY_MAP["my_func"] = "" + if _function_registry.FUNCTION_SIGNATURE_TYPE in os.environ: + del os.environ[_function_registry.FUNCTION_SIGNATURE_TYPE] + signature_type = _function_registry.get_func_signature_type("my_func", None) + + assert signature_type == "http" diff --git a/tests/test_functions/decorators/decorator.py b/tests/test_functions/decorators/decorator.py index 3bc76fc7..0c32423c 100644 --- a/tests/test_functions/decorators/decorator.py +++ b/tests/test_functions/decorators/decorator.py @@ -43,26 +43,6 @@ def function_cloudevent(cloudevent): flask.abort(500) -@functions_framework.event -def function_backgroundevent(event, context): - """Test background function. - - It writes the expected output (entry point name and the given value) to the - given file, as a response from the background function, verified by the test. - - Args: - event: The event data (as dictionary) which triggered this background - function. Must contain entries for 'value' and 'filename' keys in the - data dictionary. - context (google.cloud.functions.Context): The Cloud Functions event context. - """ - filename = event["filename"] - value = event["value"] - f = open(filename, "w") - f.write('{{"entryPoint": "function", "value": "{}"}}'.format(value)) - f.close() - - @functions_framework.http def function_http(request): """Test function which returns the requested element of the HTTP request. diff --git a/tox.ini b/tox.ini index 5d0f3971..fb76a0e8 100644 --- a/tox.ini +++ b/tox.ini @@ -11,7 +11,7 @@ deps = setenv = PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 windows-latest: PYTESTARGS = -commands = pytest {env:PYTESTARGS} {posargs} --verbose +commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] basepython=python3 From 4d3feb9d1d42d9cff918d812a78d33ed72f23654 Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Mon, 25 Oct 2021 17:46:16 -0700 Subject: [PATCH 12/13] update readme --- README.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/README.md b/README.md index 2a27ddd7..eb9accd2 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,40 @@ curl localhost:8080 # Output: Hello world! ``` +### Quickstart: Register your function using decorator + +Create an `main.py` file with the following contents: + +```python +import functions_framework + +@functions_framework.cloudevent +def hello_cloudevent(cloudevent): + return f"Received event with ID: {cloudevent['id']} and data {cloudevent.data}" + +@functions_framework.http +def hello_http(request): + return "Hello world!" + +``` + +Run the following command to run `hello_http` target locally: + +```sh +functions-framework --target=hello_http +``` + +Open http://localhost:8080/ in your browser and see *Hello world!*. + +Run the following command to run `hello_cloudevent` target locally: + +```sh +functions-framework --target=hello_cloudevent +``` + +More info on sending [CloudEvents](http://cloudevents.io) payloads, see [`examples/cloud_run_cloudevents`](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/cloud_run_cloudevents/) instruction. + + ### Quickstart: Error handling The framework includes an error handler that is similar to the From 00a27cba19255077f15b253f93cc87750a439d1a Mon Sep 17 00:00:00 2001 From: Kayla Nguyen Date: Tue, 26 Oct 2021 15:14:01 -0700 Subject: [PATCH 13/13] update readmes --- README.md | 4 ++-- examples/cloud_run_decorator/README.md | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eb9accd2..7d5c1e3d 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ Run the following command to run `hello_cloudevent` target locally: functions-framework --target=hello_cloudevent ``` -More info on sending [CloudEvents](http://cloudevents.io) payloads, see [`examples/cloud_run_cloudevents`](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/cloud_run_cloudevents/) instruction. +More info on sending [CloudEvents](http://cloudevents.io) payloads, see [`examples/cloud_run_cloudevents`](examples/cloud_run_cloudevents/) instruction. ### Quickstart: Error handling @@ -371,7 +371,7 @@ For more details on this signature type, check out the Google Cloud Functions do ## Advanced Examples -More advanced guides can be found in the [`examples/`](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/) directory. +More advanced guides can be found in the [`examples/`](examples/) directory. You can also find examples on using the CloudEvent Python SDK [here](https://github.com/cloudevents/sdk-python). ## Contributing diff --git a/examples/cloud_run_decorator/README.md b/examples/cloud_run_decorator/README.md index 7666f053..92c33b6c 100644 --- a/examples/cloud_run_decorator/README.md +++ b/examples/cloud_run_decorator/README.md @@ -1,4 +1,5 @@ ## How to run this locally + This guide shows how to run `hello_http` target locally. To test with `hello_cloudevent`, change the target accordingly in Dockerfile.