diff --git a/CHANGELOG.md b/CHANGELOG.md index 4263d416..650af986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.0.0] - 2020-07-01 +## [1.6.0] - 2020-08-19 +### Changed +- Add legacy GCF Python 3.7 behavior ([#77]) + ### Added -- Support `cloudevent` signature type ([#55], [#56]) +- Improve documentation around Dockerfiles ([#70]) +## [1.5.0] - 2020-07-06 ### Changed - Framework will consume entire request before responding ([#66]) @@ -70,8 +74,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release -[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v2.0.0...HEAD -[2.0.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v2.0.0 +[Unreleased]: https://github.com/GoogleCloudPlatform/functions-framework-python/compare/v1.6.0...HEAD +[1.6.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.6.0 +[1.5.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.5.0 [1.4.4]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.4 [1.4.3]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.3 [1.4.2]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.4.2 @@ -84,10 +89,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.0.1]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.0.1 [1.0.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.0.0 +[#77]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/77 +[#70]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/70 [#66]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/66 [#61]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/61 -[#56]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/56 -[#55]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/55 [#49]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/49 [#44]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/44 [#38]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/38 diff --git a/README.md b/README.md index a85848c9..615e331d 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ pip install functions-framework Or, for deployment, add the Functions Framework to your `requirements.txt` file: ``` -functions-framework==2.0.0 +functions-framework==1.6.0 ``` # Quickstart: Hello, World on your local machine @@ -112,7 +112,7 @@ After you've written your function, you can simply deploy it from your local mac ## Cloud Run/Cloud Run on GKE -Once you've written your function and added the Functions Framework to your `requirements.txt` file, all that's left is to create a container image. [Check out the Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) for Python to create a container image and deploy it to Cloud Run. You'll write a `Dockerfile` when you build your container. This `Dockerfile` allows you to specify exactly what goes into your container (including custom binaries, a specific operating system, and more). +Once you've written your function and added the Functions Framework to your `requirements.txt` file, all that's left is to create a container image. [Check out the Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy) for Python to create a container image and deploy it to Cloud Run. You'll write a `Dockerfile` when you build your container. This `Dockerfile` allows you to specify exactly what goes into your container (including custom binaries, a specific operating system, and more). [Here is an example `Dockerfile` that calls Functions Framework.](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/cloud_run_http) If you want even more control over the environment, you can [deploy your container image to Cloud Run on GKE](https://cloud.google.com/run/docs/quickstarts/prebuilt-deploy-gke). With Cloud Run on GKE, you can run your function on a GKE cluster, which gives you additional control over the environment (including use of GPU-based instances, longer timeouts and more). @@ -129,18 +129,13 @@ You can configure the Functions Framework using command-line flags or environmen | `--host` | `HOST` | The host on which the Functions Framework listens for requests. Default: `0.0.0.0` | | `--port` | `PORT` | The port on which the Functions Framework listens for requests. Default: `8080` | | `--target` | `FUNCTION_TARGET` | The name of the exported function to be invoked in response to requests. Default: `function` | -| `--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` or `event` or `cloudevent` | +| `--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` or `event` | | `--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` | +# Enable CloudEvents -# Enable Google Cloud Functions Events - -The Functions Framework can unmarshall incoming -Google Cloud Functions [event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) payloads to `data` and `context` objects. -These will be passed as arguments to your function when it receives a request. -Note that your function must use the `event`-style function signature: - +The Functions Framework can unmarshall incoming [CloudEvents](http://cloudevents.io) payloads to `data` and `context` objects. These will be passed as arguments to your function when it receives a request. Note that your function must use the event-style function signature: ```python def hello(data, context): @@ -148,38 +143,13 @@ def hello(data, context): print(context) ``` -To enable automatic unmarshalling, set the function signature type to `event` -using a command-line flag or an environment variable. By default, the HTTP -signature will be used and automatic event unmarshalling will be disabled. - -For more details on this signature type, check out the Google Cloud Functions -documentation on -[background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). - -See the [running example](examples/cloud_run_event). - -# Enable CloudEvents - -The Functions Framework can unmarshall incoming -[CloudEvent](http://cloudevents.io) payloads to a `cloudevent` object. -It will be passed as an argument to your function when it receives a request. -Note that your function must use the `cloudevent`-style function signature - - -```python -def hello(cloudevent): - print("Received event with ID: %s" % cloudevent.EventID()) - return 200 -``` - -To enable automatic unmarshalling, set the function signature type to `cloudevent` using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP signature type will be used and automatic event unmarshalling will be disabled. +To enable automatic unmarshalling, set the function signature type to `event` using the `--signature-type` command-line flag or the `FUNCTION_SIGNATURE_TYPE` environment variable. By default, the HTTP signature type will be used and automatic event unmarshalling will be disabled. -See the [running example](examples/cloud_run_cloudevents). +For more details on this signature type, check out the Google Cloud Functions documentation on [background functions](https://cloud.google.com/functions/docs/writing/background#cloud_pubsub_example). # Advanced Examples -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). +More advanced guides can be found in the [`examples/`](https://github.com/GoogleCloudPlatform/functions-framework-python/blob/master/examples/) directory. # Contributing diff --git a/examples/README.md b/examples/README.md index fc027bfe..33ce2a76 100644 --- a/examples/README.md +++ b/examples/README.md @@ -1,5 +1,4 @@ # Python Functions Frameworks Examples * [`cloud_run_http`](./cloud_run_http/) - Deploying an HTTP function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework -* [`cloud_run_event`](./cloud_run_event/) - Deploying a [Google Cloud Functions Event](https://cloud.google.com/functions/docs/concepts/events-triggers#events) function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework -* [`cloud_run_cloudevents`](./cloud_run_cloudevents/) - Deploying a [CloudEvent](https://github.com/cloudevents/sdk-python) function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework +* [`cloud_run_event`](./cloud_run_event/) - Deploying a CloudEvent function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework diff --git a/examples/cloud_run_cloudevents/Dockerfile b/examples/cloud_run_cloudevents/Dockerfile deleted file mode 100644 index 10163c5f..00000000 --- a/examples/cloud_run_cloudevents/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -# 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 -WORKDIR $APP_HOME -COPY . . - -# Install production dependencies. -RUN pip install gunicorn cloudevents functions-framework -RUN pip install -r requirements.txt - -# Run the web service on container startup. -CMD exec functions-framework --target=hello --signature-type=cloudevent diff --git a/examples/cloud_run_cloudevents/README.md b/examples/cloud_run_cloudevents/README.md deleted file mode 100644 index 0f3c8fe0..00000000 --- a/examples/cloud_run_cloudevents/README.md +++ /dev/null @@ -1,52 +0,0 @@ -# Deploying a CloudEvent function to Cloud Run with the Functions Framework -This sample uses the [Cloud Events SDK](https://github.com/cloudevents/sdk-python) to send and receive a CloudEvent on Cloud Run. - -## How to run this locally -Build the Docker image: - -```commandline -docker build --tag ff_example . -``` - -Run the image and bind the correct ports: - -```commandline -docker run -p:8080:8080 ff_example -``` - -Send an event to the container: - -```python -from cloudevents.sdk import converters -from cloudevents.sdk import marshaller -from cloudevents.sdk.converters import structured -from cloudevents.sdk.event import v1 -import requests -import json - -def run_structured(event, url): - http_marshaller = marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = http_marshaller.ToRequest( - event, converters.TypeStructured, json.dumps - ) - print("structured CloudEvent") - print(structured_data.getvalue()) - - response = requests.post(url, - headers=structured_headers, - data=structured_data.getvalue()) - response.raise_for_status() - -event = ( - v1.Event() - .SetContentType("application/json") - .SetData('{"name":"john"}') - .SetEventID("my-id") - .SetSource("from-galaxy-far-far-away") - .SetEventTime("tomorrow") - .SetEventType("cloudevent.greet.you") -) - -run_structured(event, "http://0.0.0.0:8080/") - -``` diff --git a/examples/cloud_run_cloudevents/requirements.txt b/examples/cloud_run_cloudevents/requirements.txt deleted file mode 100644 index 33c5f99f..00000000 --- a/examples/cloud_run_cloudevents/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -# Optionally include additional dependencies here diff --git a/examples/cloud_run_event/Dockerfile b/examples/cloud_run_event/Dockerfile index 6b31c042..b3e7ffeb 100644 --- a/examples/cloud_run_event/Dockerfile +++ b/examples/cloud_run_event/Dockerfile @@ -12,4 +12,4 @@ RUN pip install gunicorn functions-framework RUN pip install -r requirements.txt # Run the web service on container startup. -CMD exec functions-framework --target=hello --signature_type=event +CMD exec functions-framework --target=hello --signature-type=event diff --git a/examples/cloud_run_event/README.md b/examples/cloud_run_event/README.md deleted file mode 100644 index 62d34cca..00000000 --- a/examples/cloud_run_event/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Google Cloud Functions Events Example -This example demonstrates how to write an event function. Note that you can also use [CloudEvents](https://github.com/cloudevents/sdk-python) -([example](../cloud_run_cloudevents)), which is a different construct. \ No newline at end of file diff --git a/examples/cloud_run_http/Dockerfile b/examples/cloud_run_http/Dockerfile index e8ab5287..c81596c3 100644 --- a/examples/cloud_run_http/Dockerfile +++ b/examples/cloud_run_http/Dockerfile @@ -8,7 +8,7 @@ WORKDIR $APP_HOME COPY . . # Install production dependencies. -RUN pip install gunicorn functions-framework +RUN pip install functions-framework RUN pip install -r requirements.txt # Run the web service on container startup. diff --git a/examples/cloud_run_http/README.md b/examples/cloud_run_http/README.md index 6cf53980..4cbe96d4 100644 --- a/examples/cloud_run_http/README.md +++ b/examples/cloud_run_http/README.md @@ -26,14 +26,11 @@ WORKDIR $APP_HOME COPY . . # Install production dependencies. -RUN pip install gunicorn functions-framework +RUN pip install functions-framework RUN pip install -r requirements.txt -# Run the web service on container startup. Here we use the gunicorn -# webserver, with one worker process and 8 threads. -# For environments with multiple CPU cores, increase the number of workers -# to be equal to the cores available. -CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 -e FUNCTION_TARGET=hello functions_framework:app +# Run the web service on container startup. +CMD exec functions-framework --target=hello ``` Start the container locally by running `docker build` and `docker run`: diff --git a/setup.py b/setup.py index 81cc49b5..098feed5 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="2.0.0", + version="1.6.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", @@ -52,7 +52,6 @@ "click>=7.0,<8.0", "watchdog>=0.10.0", "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", - "cloudevents<1.0", ], entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py index 857c5fa8..594551d3 100644 --- a/src/functions_framework/__init__.py +++ b/src/functions_framework/__init__.py @@ -12,19 +12,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -import enum +import functools import importlib.util -import io -import json import os.path import pathlib import sys import types -import cloudevents.sdk -import cloudevents.sdk.event -import cloudevents.sdk.event.v1 -import cloudevents.sdk.marshaller import flask import werkzeug @@ -42,12 +36,6 @@ MAX_CONTENT_LENGTH = 10 * 1024 * 1024 -class _EventType(enum.Enum): - LEGACY = 1 - CLOUDEVENT_BINARY = 2 - CLOUDEVENT_STRUCTURED = 3 - - class _Event(object): """Event passed to background functions.""" @@ -80,83 +68,38 @@ def view_func(path): return view_func -def _get_cloudevent_version(): - return cloudevents.sdk.event.v1.Event() - - -def _run_legacy_event(function, request): - event_data = request.get_json() - if not event_data: - flask.abort(400) - event_object = _Event(**event_data) - data = event_object.data - context = Context(**event_object.context) - function(data, context) - - -def _run_binary_cloudevent(function, request, cloudevent_def): - data = io.BytesIO(request.get_data()) - http_marshaller = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - event = http_marshaller.FromRequest( - cloudevent_def, request.headers, data, json.load - ) - - function(event) - - -def _run_structured_cloudevent(function, request, cloudevent_def): - data = io.StringIO(request.get_data(as_text=True)) - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - event = m.FromRequest(cloudevent_def, request.headers, data, json.loads) - function(event) - - -def _get_event_type(request): - if ( +def _is_binary_cloud_event(request): + return ( request.headers.get("ce-type") and request.headers.get("ce-specversion") and request.headers.get("ce-source") and request.headers.get("ce-id") - ): - return _EventType.CLOUDEVENT_BINARY - elif request.headers.get("Content-Type") == "application/cloudevents+json": - return _EventType.CLOUDEVENT_STRUCTURED - else: - return _EventType.LEGACY + ) def _event_view_func_wrapper(function, request): def view_func(path): - if _get_event_type(request) == _EventType.LEGACY: - _run_legacy_event(function, request) - else: - # here for defensive backwards compatibility in case we make a mistake in rollout. - flask.abort( - 400, - description="The FUNCTION_SIGNATURE_TYPE for this function is set to event " - "but no Google Cloud Functions Event was given. If you are using CloudEvents set " - "FUNCTION_SIGNATURE_TYPE=cloudevent", + if _is_binary_cloud_event(request): + # Support CloudEvents in binary content mode, with data being the + # whole request body and context attributes retrieved from request + # headers. + data = request.get_data() + context = Context( + eventId=request.headers.get("ce-eventId"), + timestamp=request.headers.get("ce-timestamp"), + eventType=request.headers.get("ce-eventType"), + resource=request.headers.get("ce-resource"), ) - - return "OK" - - return view_func - - -def _cloudevent_view_func_wrapper(function, request): - def view_func(path): - cloudevent_def = _get_cloudevent_version() - event_type = _get_event_type(request) - if event_type == _EventType.CLOUDEVENT_STRUCTURED: - _run_structured_cloudevent(function, request, cloudevent_def) - elif event_type == _EventType.CLOUDEVENT_BINARY: - _run_binary_cloudevent(function, request, cloudevent_def) + function(data, context) else: - flask.abort( - 400, - description="Function was defined with FUNCTION_SIGNATURE_TYPE=cloudevent " - " but it did not receive a cloudevent as a request.", - ) + # This is a regular CloudEvent + event_data = request.get_json() + if not event_data: + flask.abort(400) + event_object = _Event(**event_data) + data = event_object.data + context = Context(**event_object.context) + function(data, context) return "OK" @@ -231,6 +174,19 @@ def create_app(target=None, source=None, signature_type=None): app = flask.Flask(target, template_folder=template_folder) app.config["MAX_CONTENT_LENGTH"] = MAX_CONTENT_LENGTH + # 6. 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 + + def handle_none(rv): + if rv is None: + rv = "OK" + return app.make_response_original(rv) + + app.make_response = handle_none + # Extract the target function from the source file try: function = getattr(source_module, target) @@ -263,27 +219,19 @@ def create_app(target=None, source=None, signature_type=None): 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" or signature_type == "cloudevent": + elif signature_type == "event": app.url_map.add( werkzeug.routing.Rule( - "/", defaults={"path": ""}, endpoint=signature_type, methods=["POST"] + "/", defaults={"path": ""}, endpoint="run", methods=["POST"] ) ) app.url_map.add( - werkzeug.routing.Rule( - "/", endpoint=signature_type, methods=["POST"] - ) + 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: "" - - # Add the view functions - app.view_functions["event"] = _event_view_func_wrapper(function, flask.request) - app.view_functions["cloudevent"] = _cloudevent_view_func_wrapper( - function, flask.request - ) else: raise FunctionsFrameworkException( "Invalid signature type: {signature_type}".format( diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 663ea50f..4fe6e427 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"]), default="http", ) @click.option("--host", envvar="HOST", type=click.STRING, default="0.0.0.0") diff --git a/tests/test_cloudevent_functions.py b/tests/test_cloudevent_functions.py deleted file mode 100644 index 17e6f23c..00000000 --- a/tests/test_cloudevent_functions.py +++ /dev/null @@ -1,120 +0,0 @@ -# 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. -import json -import pathlib - -import cloudevents.sdk -import cloudevents.sdk.event.v1 -import cloudevents.sdk.event.v03 -import cloudevents.sdk.marshaller -import pytest - -from functions_framework import LazyWSGIApp, create_app, exceptions - -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_1_0(): - event = ( - cloudevents.sdk.event.v1.Event() - .SetContentType("application/json") - .SetData('{"name":"john"}') - .SetEventID("my-id") - .SetSource("from-galaxy-far-far-away") - .SetEventTime("tomorrow") - .SetEventType("cloudevent.greet.you") - ) - return event - - -@pytest.fixture -def cloudevent_0_3(): - event = ( - cloudevents.sdk.event.v03.Event() - .SetContentType("application/json") - .SetData('{"name":"john"}') - .SetEventID("my-id") - .SetSource("from-galaxy-far-far-away") - .SetEventTime("tomorrow") - .SetEventType("cloudevent.greet.you") - ) - return event - - -def test_event_1_0(cloudevent_1_0): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() - - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = m.ToRequest( - cloudevent_1_0, cloudevents.sdk.converters.TypeStructured, json.dumps - ) - - resp = client.post("/", headers=structured_headers, data=structured_data.getvalue()) - assert resp.status_code == 200 - assert resp.data == b"OK" - - -def test_binary_event_1_0(cloudevent_1_0): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() - - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - - binary_headers, binary_data = m.ToRequest( - cloudevent_1_0, cloudevents.sdk.converters.TypeBinary, json.dumps - ) - - resp = client.post("/", headers=binary_headers, data=binary_data) - - assert resp.status_code == 200 - assert resp.data == b"OK" - - -def test_event_0_3(cloudevent_0_3): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() - - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = m.ToRequest( - cloudevent_0_3, cloudevents.sdk.converters.TypeStructured, json.dumps - ) - - resp = client.post("/", headers=structured_headers, data=structured_data.getvalue()) - assert resp.status_code == 200 - assert resp.data == b"OK" - - -def test_non_cloudevent_(): - source = TEST_FUNCTIONS_DIR / "cloudevents" / "main.py" - target = "function" - - client = create_app(target, source, "cloudevent").test_client() - - resp = client.post("/", json="{not_event}") - assert resp.status_code == 400 - assert resp.data != b"OK" diff --git a/tests/test_event_functions.py b/tests/test_event_functions.py deleted file mode 100644 index 7b274672..00000000 --- a/tests/test_event_functions.py +++ /dev/null @@ -1,213 +0,0 @@ -# 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. -import json -import pathlib -import re - -import cloudevents.sdk -import cloudevents.sdk.event.v1 -import cloudevents.sdk.marshaller -import pytest - -from functions_framework import LazyWSGIApp, create_app, exceptions - -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 background_json(tmpdir): - return { - "context": { - "eventId": "some-eventId", - "timestamp": "some-timestamp", - "eventType": "some-eventType", - "resource": "some-resource", - }, - "data": {"filename": str(tmpdir / "filename.txt"), "value": "some-value"}, - } - - -def test_non_legacy_event_fails(): - cloudevent = ( - cloudevents.sdk.event.v1.Event() - .SetContentType("application/json") - .SetData('{"name":"john"}') - .SetEventID("my-id") - .SetSource("from-galaxy-far-far-away") - .SetEventTime("tomorrow") - .SetEventType("cloudevent.greet.you") - ) - m = cloudevents.sdk.marshaller.NewDefaultHTTPMarshaller() - structured_headers, structured_data = m.ToRequest( - cloudevent, cloudevents.sdk.converters.TypeStructured, json.dumps - ) - - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - resp = client.post("/", headers=structured_headers, data=structured_data.getvalue()) - assert resp.status_code == 400 - assert resp.data != b"OK" - - -def test_background_function_executes(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - - -def test_background_function_supports_get(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.get("/") - assert resp.status_code == 200 - - -def test_background_function_executes_entry_point_one(background_json): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "myFunctionFoo" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - - -def test_background_function_executes_entry_point_two(background_json): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "myFunctionBar" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - - -def test_multiple_calls(background_json): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "myFunctionFoo" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - resp = client.post("/", json=background_json) - assert resp.status_code == 200 - - -def test_pubsub_payload(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/", json=background_json) - - assert resp.status_code == 200 - assert resp.data == b"OK" - - with open(background_json["data"]["filename"]) as f: - assert f.read() == '{{"entryPoint": "function", "value": "{}"}}'.format( - background_json["data"]["value"] - ) - - -def test_background_function_no_data(background_json): - source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" - target = "function" - - client = create_app(target, source, "event").test_client() - - resp = client.post("/") - assert resp.status_code == 400 - - -def test_invalid_function_definition_multiple_entry_points(): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "function" - - with pytest.raises(exceptions.MissingTargetException) as excinfo: - create_app(target, source, "event") - - assert re.match( - "File .* is expected to contain a function named function", str(excinfo.value) - ) - - -def test_invalid_function_definition_multiple_entry_points_invalid_function(): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "invalidFunction" - - with pytest.raises(exceptions.MissingTargetException) as excinfo: - create_app(target, source, "event") - - assert re.match( - "File .* is expected to contain a function named invalidFunction", - str(excinfo.value), - ) - - -def test_invalid_function_definition_multiple_entry_points_not_a_function(): - source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" - target = "notAFunction" - - with pytest.raises(exceptions.InvalidTargetTypeException) as excinfo: - create_app(target, source, "event") - - assert re.match( - "The function defined in file .* as notAFunction needs to be of type " - "function. Got: .*", - str(excinfo.value), - ) - - -def test_invalid_function_definition_function_syntax_error(): - source = TEST_FUNCTIONS_DIR / "background_load_error" / "main.py" - target = "function" - - with pytest.raises(SyntaxError) as excinfo: - create_app(target, source, "event") - - assert any( - ( - "invalid syntax" in str(excinfo.value), # Python <3.8 - "unmatched ')'" in str(excinfo.value), # Python >3.8 - ) - ) - - -def test_invalid_function_definition_missing_dependency(): - source = TEST_FUNCTIONS_DIR / "background_missing_dependency" / "main.py" - target = "function" - - with pytest.raises(_ModuleNotFoundError) as excinfo: - create_app(target, source, "event") - - assert "No module named 'nonexistentpackage'" in str(excinfo.value) diff --git a/tests/test_functions.py b/tests/test_functions.py index 792a646e..4698049e 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -11,6 +11,8 @@ # 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 import pathlib import re import time @@ -22,7 +24,8 @@ from functions_framework import LazyWSGIApp, create_app, exceptions -TEST_FUNCTIONS_DIR = pathlib.Path(__file__).resolve().parent / "test_functions" +TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions" + # Python 3.5: ModuleNotFoundError does not exist try: @@ -167,6 +170,87 @@ def test_http_function_execution_time(): assert resp.data == b"OK" +def test_background_function_executes(background_json): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_background_function_supports_get(background_json): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + client = create_app(target, source, "event").test_client() + + resp = client.get("/") + assert resp.status_code == 200 + + +def test_background_function_executes_entry_point_one(background_json): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "myFunctionFoo" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_background_function_executes_entry_point_two(background_json): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "myFunctionBar" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_multiple_calls(background_json): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "myFunctionFoo" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + resp = client.post("/", json=background_json) + assert resp.status_code == 200 + + +def test_pubsub_payload(background_json): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/", json=background_json) + + assert resp.status_code == 200 + assert resp.data == b"OK" + + with open(background_json["data"]["filename"]) as f: + assert f.read() == '{{"entryPoint": "function", "value": "{}"}}'.format( + background_json["data"]["value"] + ) + + +def test_background_function_no_data(background_json): + source = TEST_FUNCTIONS_DIR / "background_trigger" / "main.py" + target = "function" + + client = create_app(target, source, "event").test_client() + + resp = client.post("/") + assert resp.status_code == 400 + + def test_invalid_function_definition_missing_function_file(): source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py" target = "functions" @@ -179,6 +263,70 @@ def test_invalid_function_definition_missing_function_file(): ) +def test_invalid_function_definition_multiple_entry_points(): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "function" + + with pytest.raises(exceptions.MissingTargetException) as excinfo: + create_app(target, source, "event") + + assert re.match( + "File .* is expected to contain a function named function", str(excinfo.value) + ) + + +def test_invalid_function_definition_multiple_entry_points_invalid_function(): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "invalidFunction" + + with pytest.raises(exceptions.MissingTargetException) as excinfo: + create_app(target, source, "event") + + assert re.match( + "File .* is expected to contain a function named invalidFunction", + str(excinfo.value), + ) + + +def test_invalid_function_definition_multiple_entry_points_not_a_function(): + source = TEST_FUNCTIONS_DIR / "background_multiple_entry_points" / "main.py" + target = "notAFunction" + + with pytest.raises(exceptions.InvalidTargetTypeException) as excinfo: + create_app(target, source, "event") + + assert re.match( + "The function defined in file .* as notAFunction needs to be of type " + "function. Got: .*", + str(excinfo.value), + ) + + +def test_invalid_function_definition_function_syntax_error(): + source = TEST_FUNCTIONS_DIR / "background_load_error" / "main.py" + target = "function" + + with pytest.raises(SyntaxError) as excinfo: + create_app(target, source, "event") + + assert any( + ( + "invalid syntax" in str(excinfo.value), # Python <3.8 + "unmatched ')'" in str(excinfo.value), # Python >3.8 + ) + ) + + +def test_invalid_function_definition_missing_dependency(): + source = TEST_FUNCTIONS_DIR / "background_missing_dependency" / "main.py" + target = "function" + + with pytest.raises(_ModuleNotFoundError) as excinfo: + create_app(target, source, "event") + + assert "No module named 'nonexistentpackage'" in str(excinfo.value) + + def test_invalid_configuration(): with pytest.raises(exceptions.InvalidConfigurationException) as excinfo: create_app(None, None, None) @@ -308,3 +456,42 @@ def test_class_in_main_is_in_right_module(): resp = client.get("/") assert resp.status_code == 200 + + +def test_function_returns_none(): + source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + resp = client.get("/") + + assert resp.status_code == 500 + + +def test_legacy_function_check_env(monkeypatch): + source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" + target = "function" + + monkeypatch.setenv("ENTRY_POINT", target) + + client = create_app(target, source).test_client() + resp = client.post("/", json={"mode": "FUNCTION_TRIGGER_TYPE"}) + assert resp.status_code == 200 + assert resp.data == b"http" + + resp = client.post("/", json={"mode": "FUNCTION_NAME"}) + assert resp.status_code == 200 + assert resp.data.decode("utf-8") == target + + +def test_legacy_function_returns_none(monkeypatch): + source = TEST_FUNCTIONS_DIR / "returns_none" / "main.py" + target = "function" + + monkeypatch.setenv("ENTRY_POINT", target) + + client = create_app(target, source).test_client() + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.data == b"OK" diff --git a/tests/test_functions/cloudevents/main.py b/tests/test_functions/cloudevents/main.py deleted file mode 100644 index f2fdb6f3..00000000 --- a/tests/test_functions/cloudevents/main.py +++ /dev/null @@ -1,40 +0,0 @@ -# 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. - -"""Function used to test handling Cloud Event functions.""" -import flask - - -def function(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 Cloud event as defined by https://github.com/cloudevents/sdk-python. - - Returns: - HTTP status code indicating whether valid event was sent or not. - - """ - valid_event = ( - cloudevent.EventID() == "my-id" - and cloudevent.Data() == '{"name":"john"}' - and cloudevent.Source() == "from-galaxy-far-far-away" - and cloudevent.EventTime() == "tomorrow" - and cloudevent.EventType() == "cloudevent.greet.you" - ) - - if not valid_event: - flask.abort(500) diff --git a/examples/cloud_run_cloudevents/main.py b/tests/test_functions/returns_none/main.py similarity index 69% rename from examples/cloud_run_cloudevents/main.py rename to tests/test_functions/returns_none/main.py index 94b2734a..f6a4acaa 100644 --- a/examples/cloud_run_cloudevents/main.py +++ b/tests/test_functions/returns_none/main.py @@ -12,10 +12,16 @@ # See the License for the specific language governing permissions and # limitations under the License. -# This sample creates a function that accepts a Cloud Event per -# https://github.com/cloudevents/sdk-python -import sys +def function(request): + """Test HTTP function when using legacy GCF behavior. -def hello(cloudevent): - print("Received event with ID: %s" % cloudevent.EventID(), file=sys.stdout, flush=True) + The function returns None, which should be a 200 response. + + Args: + request: The HTTP request which triggered this function. + + Returns: + None. + """ + return None diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py index a9e13bb7..51dad087 100644 --- a/tests/test_view_functions.py +++ b/tests/test_view_functions.py @@ -60,6 +60,41 @@ def test_event_view_func_wrapper(monkeypatch): ] +def test_binary_event_view_func_wrapper(monkeypatch): + data = pretend.stub() + request = pretend.stub( + headers={ + "ce-type": "something", + "ce-specversion": "something", + "ce-source": "something", + "ce-id": "something", + "ce-eventId": "some-eventId", + "ce-timestamp": "some-timestamp", + "ce-eventType": "some-eventType", + "ce-resource": "some-resource", + }, + get_data=lambda: data, + ) + + context_stub = pretend.stub() + context_class = pretend.call_recorder(lambda *a, **kw: context_stub) + monkeypatch.setattr(functions_framework, "Context", context_class) + function = pretend.call_recorder(lambda data, context: "Hello") + + view_func = functions_framework._event_view_func_wrapper(function, request) + view_func("/some/path") + + assert function.calls == [pretend.call(data, context_stub)] + assert context_class.calls == [ + pretend.call( + eventId="some-eventId", + timestamp="some-timestamp", + eventType="some-eventType", + resource="some-resource", + ) + ] + + def test_legacy_event_view_func_wrapper(monkeypatch): data = pretend.stub() json = {