From b2bbaf0b4be05af78bb2f2d4cc531ae2003127a2 Mon Sep 17 00:00:00 2001 From: Dustin Ingram Date: Mon, 4 Nov 2019 16:53:40 -0600 Subject: [PATCH] Initial commit --- .gitignore | 6 + .travis.yml | 18 + CONTRIBUTING.md | 28 ++ LICENSE | 202 ++++++++++ README.md | 153 ++++++++ conftest.py | 28 ++ examples/README.md | 3 + examples/cloud_run/Dockerfile | 18 + examples/cloud_run/README.md | 70 ++++ examples/cloud_run/main.py | 16 + examples/cloud_run/requirements.txt | 1 + setup.cfg | 10 + setup.py | 59 +++ src/functions_framework/__init__.py | 221 +++++++++++ src/functions_framework/cli.py | 36 ++ src/functions_framework/exceptions.py | 33 ++ src/google/__init__.py | 22 ++ src/google/cloud/__init__.py | 22 ++ src/google/cloud/functions/__init__.py | 13 + src/google/cloud/functions/context.py | 19 + src/google/cloud/functions_v1/__init__.py | 13 + src/google/cloud/functions_v1/context.py | 33 ++ .../cloud/functions_v1beta2/__init__.py | 13 + src/google/cloud/functions_v1beta2/context.py | 33 ++ tests/test_cli.py | 77 ++++ tests/test_functions.py | 358 ++++++++++++++++++ .../background_load_error/main.py | 29 ++ .../background_missing_dependency/main.py | 32 ++ .../background_multiple_entry_points/main.py | 71 ++++ .../test_functions/background_trigger/main.py | 36 ++ tests/test_functions/http_check_env/main.py | 35 ++ .../http_flask_render_template/main.py | 43 +++ .../templates/hello.html | 6 + .../test_functions/http_method_check/main.py | 27 ++ .../test_functions/http_request_check/main.py | 39 ++ tests/test_functions/http_trigger/main.py | 46 +++ .../test_functions/http_trigger_sleep/main.py | 32 ++ tests/test_functions/http_with_import/foo.py | 15 + tests/test_functions/http_with_import/main.py | 29 ++ .../missing_function_file/dummy_file | 1 + tests/test_view_functions.py | 125 ++++++ tox.ini | 26 ++ 42 files changed, 2097 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 conftest.py create mode 100644 examples/README.md create mode 100644 examples/cloud_run/Dockerfile create mode 100644 examples/cloud_run/README.md create mode 100644 examples/cloud_run/main.py create mode 100644 examples/cloud_run/requirements.txt create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 src/functions_framework/__init__.py create mode 100644 src/functions_framework/cli.py create mode 100644 src/functions_framework/exceptions.py create mode 100644 src/google/__init__.py create mode 100644 src/google/cloud/__init__.py create mode 100644 src/google/cloud/functions/__init__.py create mode 100644 src/google/cloud/functions/context.py create mode 100644 src/google/cloud/functions_v1/__init__.py create mode 100644 src/google/cloud/functions_v1/context.py create mode 100644 src/google/cloud/functions_v1beta2/__init__.py create mode 100644 src/google/cloud/functions_v1beta2/context.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_functions.py create mode 100644 tests/test_functions/background_load_error/main.py create mode 100644 tests/test_functions/background_missing_dependency/main.py create mode 100644 tests/test_functions/background_multiple_entry_points/main.py create mode 100644 tests/test_functions/background_trigger/main.py create mode 100644 tests/test_functions/http_check_env/main.py create mode 100644 tests/test_functions/http_flask_render_template/main.py create mode 100644 tests/test_functions/http_flask_render_template/templates/hello.html create mode 100644 tests/test_functions/http_method_check/main.py create mode 100644 tests/test_functions/http_request_check/main.py create mode 100644 tests/test_functions/http_trigger/main.py create mode 100644 tests/test_functions/http_trigger_sleep/main.py create mode 100644 tests/test_functions/http_with_import/foo.py create mode 100644 tests/test_functions/http_with_import/main.py create mode 100644 tests/test_functions/missing_function_file/dummy_file create mode 100644 tests/test_view_functions.py create mode 100644 tox.ini diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..98e18281 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.egg-info/ +*.py[cod] +.tox/ +__pycache__/ +build/ +dist/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 00000000..82edfebe --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: python + +matrix: + include: + - python: 3.5 + env: TOXENV=py35 + - python: 3.6 + env: TOXENV=py36 + - python: 3.7 + env: TOXENV=py37 + - python: 3.8 + env: TOXENV=py38 + - python: 3.8 + env: TOXENV=lint + +install: pip install tox + +script: tox diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..939e5341 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,28 @@ +# How to Contribute + +We'd love to accept your patches and contributions to this project. There are +just a few small guidelines you need to follow. + +## Contributor License Agreement + +Contributions to this project must be accompanied by a Contributor License +Agreement. You (or your employer) retain the copyright to your contribution; +this simply gives us permission to use and redistribute your contributions as +part of the project. Head over to to see +your current agreements on file or to sign a new one. + +You generally only need to submit a CLA once, so if you've already submitted one +(even if it was for a different project), you probably don't need to do it +again. + +## Code reviews + +All submissions, including submissions by project members, require review. We +use GitHub pull requests for this purpose. Consult +[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more +information on using pull requests. + +## Community Guidelines + +This project follows [Google's Open Source Community +Guidelines](https://opensource.google.com/conduct/). diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..d6456956 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/README.md b/README.md new file mode 100644 index 00000000..4976d0a3 --- /dev/null +++ b/README.md @@ -0,0 +1,153 @@ +# Functions Framework for Python +An open source FaaS (Function as a service) framework for writing portable +Python functions -- brought to you by the Google Cloud Functions team. + +The Functions Framework lets you write lightweight functions that run in many +different environments, including: + +* [Google Cloud Functions](https://cloud.google.com/functions/) +* Your local development machine +* [Cloud Run and Cloud Run for Anthos](https://cloud.google.com/run/) +* [Knative](https://github.com/knative/)-based environments + +The framework allows you to go from: + +```python +def hello(request): + return "Hello world!" +``` + +To: + +```sh +curl http://my-url +# Output: Hello world! +``` + +All without needing to worry about writing an HTTP server or complicated request handling logic. + +# Features + +* Spin up a local development server for quick testing +* Invoke a function in response to a request +* Automatically unmarshal events conforming to the [CloudEvents](https://cloudevents.io/) spec +* Portable between serverless platforms + +# Installation + +Install the Functions Framework via `pip`: + +```sh +pip install functions-framework +``` + +Or, for deployment, add the Functions Framework to your `requirements.txt` file: + +``` +functions-framework==1.0.0 +``` + +# Quickstart: Hello, World on your local machine + +Create an `main.py` file with the following contents: + +```python +def hello(request): + return "Hello world!" +``` + +Run the following command: + +```sh +functions-framework --target=hello +``` + +Open http://localhost:8080/ in your browser and see *Hello world!*. + + +# Quickstart: Set up a new project + +Create a `main.py` file with the following contents: + +```python +def hello(request): + return "Hello world!" +``` + +Now install the Functions Framework: + +```sh +pip install functions-framework +``` + +Use the `functions-framework` command to start the built-in local development server: + +```sh +functions-framework --target hello + * Serving Flask app "hello" (lazy loading) + * Environment: production + WARNING: This is a development server. Do not use it in a production deployment. + Use a production WSGI server instead. + * Debug mode: off + * Running on http://0.0.0.0:8080/ (Press CTRL+C to quit) +``` + +Send requests to this function using `curl` from another terminal window: + +```sh +curl localhost:8080 +# Output: Hello world! +``` + +# Run your function on serverless platforms + +## Google Cloud Functions + +This Functions Framework is based on the [Python Runtime on Google Cloud Functions](https://cloud.google.com/functions/docs/concepts/python-runtime). + +On Cloud Functions, using the Functions Framework is not necessary: you don't need to add it to your `requirements.txt` file. + +After you've written your function, you can simply deploy it from your local machine using the `gcloud` command-line tool. [Check out the Cloud Functions quickstart](https://cloud.google.com/functions/docs/quickstart). + +## 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). + +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). + +## Container environments based on Knative + +Cloud Run and Cloud Run on GKE both implement the [Knative Serving API](https://www.knative.dev/docs/). The Functions Framework is designed to be compatible with Knative environments. Just build and deploy your container to a Knative environment. + +# Configure the Functions Framework + +You can configure the Functions Framework using command-line flags or environment variables. If you specify both, the environment variable will be ignored. + +Command-line flag | Environment variable | Description +------------------------- | ------------------------- | ----------- +`--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` +`--source` | `FUNCTION_SOURCE` | The path to the file containing your function. Default: `main.py` (in the current working directory) + +# Enable CloudEvents + +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): + print(data) + print(context) +``` + +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. + +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. + +# Contributing + +Contributions to this library are welcome and encouraged. See [CONTRIBUTING](CONTRIBUTING.md) for more information on how to get started. diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..b8d44d43 --- /dev/null +++ b/conftest.py @@ -0,0 +1,28 @@ +# 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 os + +import pytest + + +@pytest.fixture(scope="function", autouse=True) +def isolate_environment(): + """Ensure any changes to the environment are isolated to individual tests""" + _environ = os.environ.copy() + try: + yield + finally: + os.environ.clear() + os.environ.update(_environ) diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 00000000..e6bc3e0f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,3 @@ +# Python Functions Frameworks Examples + +* [`cloud_run`](./cloud_run/) - Deploying a function to [Cloud Run](http://cloud.google.com/run) with the Functions Framework diff --git a/examples/cloud_run/Dockerfile b/examples/cloud_run/Dockerfile new file mode 100644 index 00000000..b211a229 --- /dev/null +++ b/examples/cloud_run/Dockerfile @@ -0,0 +1,18 @@ +# 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 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 diff --git a/examples/cloud_run/README.md b/examples/cloud_run/README.md new file mode 100644 index 00000000..2278f120 --- /dev/null +++ b/examples/cloud_run/README.md @@ -0,0 +1,70 @@ +# Deploy a function to Cloud Run + +This guide will show you how to deploy the following example function to [Cloud Run](https://cloud.google.com/run): + +```python +def hello(request): + return "Hello world!" +``` + +This guide assumes your Python function is defined in a `main.py` file and dependencies are specified in `requirements.txt` file. + +## Running your function in a container + +To run your function in a container, create a `Dockerfile` with the following contents: + +```Dockerfile +# 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 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 +``` + +Start the container locally by running `docker build` and `docker run`: + +```sh +docker build -t helloworld . && docker run --rm -p 8080:8080 -e PORT=8080 helloworld +``` + +Send requests to this function using `curl` from another terminal window: + +```sh +curl localhost:8080 +# Output: Hello world! +``` + +## Configure gcloud + +To use Docker with gcloud, [configure the Docker credential helper](https://cloud.google.com/container-registry/docs/advanced-authentication): + +```sh +gcloud auth configure-docker +``` + +## Deploy a Container + +You can deploy your containerized function to Cloud Run by following the [Cloud Run quickstart](https://cloud.google.com/run/docs/quickstarts/build-and-deploy). + +Use the `docker` and `gcloud` CLIs to build and deploy a container to Cloud Run, replacing `[PROJECT-ID]` with the project id and `helloworld` with a different image name if necessary: + +```sh +docker build -t gcr.io/[PROJECT-ID]/helloworld . +docker push gcr.io/[PROJECT-ID]/helloworld +gcloud run deploy helloworld --image gcr.io/[PROJECT-ID]/helloworld --region us-central1 +``` + +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). diff --git a/examples/cloud_run/main.py b/examples/cloud_run/main.py new file mode 100644 index 00000000..03640226 --- /dev/null +++ b/examples/cloud_run/main.py @@ -0,0 +1,16 @@ +# 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. + +def hello(request): + return "Hello world!" diff --git a/examples/cloud_run/requirements.txt b/examples/cloud_run/requirements.txt new file mode 100644 index 00000000..33c5f99f --- /dev/null +++ b/examples/cloud_run/requirements.txt @@ -0,0 +1 @@ +# Optionally include additional dependencies here diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..26b05dba --- /dev/null +++ b/setup.cfg @@ -0,0 +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 diff --git a/setup.py b/setup.py new file mode 100644 index 00000000..4965edb7 --- /dev/null +++ b/setup.py @@ -0,0 +1,59 @@ +# 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 io import open +from os import path + +from setuptools import find_packages, setup + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, "README.md"), encoding="utf-8") as f: + long_description = f.read() + +setup( + name="functions-framework", + version="1.0.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", + url="https://github.com/googlecloudplatform/functions-framework-python", + author="Google LLC", + author_email="googleapis-packages@google.com", + classifiers=[ + "Development Status :: 3 - Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + ], + keywords="functions-framework", + packages=find_packages(where="src"), + namespace_packages=["google", "google.cloud"], + package_dir={"": "src"}, + python_requires=">=3.5, <4", + install_requires=["flask>=1.0<=2.0", "click>=7.0<=8.0"], + extras_require={"test": ["pytest", "tox"]}, + entry_points={ + "console_scripts": [ + "functions-framework=functions_framework.cli:cli", + "functions_framework=functions_framework.cli:cli", + "ff=functions_framework.cli:cli", + ] + }, +) diff --git a/src/functions_framework/__init__.py b/src/functions_framework/__init__.py new file mode 100644 index 00000000..52416f58 --- /dev/null +++ b/src/functions_framework/__init__.py @@ -0,0 +1,221 @@ +# 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 functools +import importlib.util +import os.path +import pathlib +import sys +import types + +import flask +import werkzeug + +from functions_framework.exceptions import ( + FunctionsFrameworkException, + InvalidConfigurationException, + InvalidTargetTypeException, + MissingSourceException, + MissingTargetException, +) +from google.cloud.functions.context import Context + +DEFAULT_SOURCE = os.path.realpath("./main.py") +DEFAULT_SIGNATURE_TYPE = "http" + + +class _Event(object): + """Event passed to background functions.""" + + # Supports both v1beta1 and v1beta2 event formats. + def __init__( + self, + context=None, + data="", + eventId="", + timestamp="", + eventType="", + resource="", + **kwargs + ): + self.context = context + if not self.context: + self.context = { + "eventId": eventId, + "timestamp": timestamp, + "eventType": eventType, + "resource": resource, + } + self.data = data + + +def _http_view_func_wrapper(function, request): + def view_func(path): + return function(request._get_current_object()) + + return view_func + + +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") + ) + + +def _event_view_func_wrapper(function, request): + def view_func(path): + 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"), + ) + function(data, context) + else: + # This is a regular CloudEvent + event_data = request.get_json() + event_object = _Event(**event_data) + data = event_object.data + context = Context(**event_object.context) + function(data, context) + + return "OK" + + return view_func + + +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) + + # Set the template folder relative to the source path + # Python 3.5: join does not support PosixPath + template_folder = str(pathlib.Path(source).parent / "templates") + + if not os.path.exists(source): + raise MissingSourceException( + "File {source} that is expected to define function doesn't exist".format( + source=source + ) + ) + + # 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 + spec = importlib.util.spec_from_file_location("main", source) + source_module = importlib.util.module_from_spec(spec) + sys.path.append(os.path.dirname(os.path.realpath(source))) + spec.loader.exec_module(source_module) + + app = flask.Flask(target, template_folder=template_folder) + + # Extract the target function from the source file + try: + function = getattr(source_module, target) + except AttributeError: + raise MissingTargetException( + "File {source} is expected to contain a function named {target}".format( + source=source, target=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("/", endpoint="run")) + app.view_functions["run"] = _http_view_func_wrapper(function, flask.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) + else: + raise FunctionsFrameworkException( + "Invalid signature type: {signature_type}".format( + signature_type=signature_type + ) + ) + + return app + + +class LazyWSGIApp: + """ + Wrap the WSGI app in a lazily initialized wrapper to prevent initialization + at import-time + """ + + def __init__(self, target=None, source=None, signature_type=None): + # Support HTTP frameworks which support WSGI callables. + # Note: this ability is currently broken in Gunicorn 20.0, and + # environment variables should be used for configuration instead: + # https://github.com/benoitc/gunicorn/issues/2159 + self.target = target + self.source = source + self.signature_type = signature_type + + # Placeholder for the app which will be initialized on first call + self.app = None + + def __call__(self, *args, **kwargs): + if not self.app: + self.app = create_app(self.target, self.source, self.signature_type) + return self.app(*args, **kwargs) + + +app = LazyWSGIApp() diff --git a/src/functions_framework/cli.py b/src/functions_framework/cli.py new file mode 100644 index 00000000..8fb295ba --- /dev/null +++ b/src/functions_framework/cli.py @@ -0,0 +1,36 @@ +# 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 os + +import click + +from functions_framework import create_app + + +@click.command() +@click.option("--target", envvar="FUNCTION_TARGET", type=click.STRING, required=True) +@click.option("--source", envvar="FUNCTION_SOURCE", type=click.Path(), default=None) +@click.option( + "--signature_type", + envvar="FUNCTION_SIGNATURE_TYPE", + type=click.Choice(["http", "event"]), + default="http", +) +@click.option("--port", envvar="PORT", type=click.INT, default=8080) +@click.option("--debug", envvar="DEBUG", type=click.BOOL, default=False) +def cli(target, source, signature_type, port, debug): + host = "0.0.0.0" + app = create_app(target, source, signature_type) + app.run(host, port, debug) diff --git a/src/functions_framework/exceptions.py b/src/functions_framework/exceptions.py new file mode 100644 index 00000000..970da5f4 --- /dev/null +++ b/src/functions_framework/exceptions.py @@ -0,0 +1,33 @@ +# 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. + + +class FunctionsFrameworkException(Exception): + pass + + +class InvalidConfigurationException(FunctionsFrameworkException): + pass + + +class InvalidTargetTypeException(FunctionsFrameworkException): + pass + + +class MissingSourceException(FunctionsFrameworkException): + pass + + +class MissingTargetException(FunctionsFrameworkException): + pass diff --git a/src/google/__init__.py b/src/google/__init__.py new file mode 100644 index 00000000..72a55585 --- /dev/null +++ b/src/google/__init__.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. + +try: + import pkg_resources + + pkg_resources.declare_namespace(__name__) +except ImportError: + import pkgutil + + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/src/google/cloud/__init__.py b/src/google/cloud/__init__.py new file mode 100644 index 00000000..72a55585 --- /dev/null +++ b/src/google/cloud/__init__.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. + +try: + import pkg_resources + + pkg_resources.declare_namespace(__name__) +except ImportError: + import pkgutil + + __path__ = pkgutil.extend_path(__path__, __name__) diff --git a/src/google/cloud/functions/__init__.py b/src/google/cloud/functions/__init__.py new file mode 100644 index 00000000..6913f02e --- /dev/null +++ b/src/google/cloud/functions/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/src/google/cloud/functions/context.py b/src/google/cloud/functions/context.py new file mode 100644 index 00000000..665d8b29 --- /dev/null +++ b/src/google/cloud/functions/context.py @@ -0,0 +1,19 @@ +# 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. + +"""Definition of types used by Cloud Functions in Python..""" + +from google.cloud.functions_v1.context import Context + +__all__ = ["Context"] diff --git a/src/google/cloud/functions_v1/__init__.py b/src/google/cloud/functions_v1/__init__.py new file mode 100644 index 00000000..6913f02e --- /dev/null +++ b/src/google/cloud/functions_v1/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/src/google/cloud/functions_v1/context.py b/src/google/cloud/functions_v1/context.py new file mode 100644 index 00000000..12670867 --- /dev/null +++ b/src/google/cloud/functions_v1/context.py @@ -0,0 +1,33 @@ +# 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. + +"""Definition of the context type used by Cloud Functions in Python.""" + + +class Context(object): + """Context passed to background functions.""" + + def __init__(self, eventId="", timestamp="", eventType="", resource=""): + self.event_id = eventId + self.timestamp = timestamp + self.event_type = eventType + self.resource = resource + + def __str__(self): + return "{event_id: %s, timestamp: %s, event_type: %s, resource: %s}" % ( + self.event_id, + self.timestamp, + self.event_type, + self.resource, + ) diff --git a/src/google/cloud/functions_v1beta2/__init__.py b/src/google/cloud/functions_v1beta2/__init__.py new file mode 100644 index 00000000..6913f02e --- /dev/null +++ b/src/google/cloud/functions_v1beta2/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/src/google/cloud/functions_v1beta2/context.py b/src/google/cloud/functions_v1beta2/context.py new file mode 100644 index 00000000..12670867 --- /dev/null +++ b/src/google/cloud/functions_v1beta2/context.py @@ -0,0 +1,33 @@ +# 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. + +"""Definition of the context type used by Cloud Functions in Python.""" + + +class Context(object): + """Context passed to background functions.""" + + def __init__(self, eventId="", timestamp="", eventType="", resource=""): + self.event_id = eventId + self.timestamp = timestamp + self.event_type = eventType + self.resource = resource + + def __str__(self): + return "{event_id: %s, timestamp: %s, event_type: %s, resource: %s}" % ( + self.event_id, + self.timestamp, + self.event_type, + self.resource, + ) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 00000000..f6fac8ff --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,77 @@ +# 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 pretend +import pytest + +from click.testing import CliRunner + +import functions_framework + +from functions_framework.cli import cli + + +@pytest.fixture +def create_app(monkeypatch): + create_app = pretend.call_recorder( + lambda *a, **kw: pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + ) + monkeypatch.setattr(functions_framework.cli, "create_app", create_app) + return create_app + + +def test_cli_no_arguments(): + runner = CliRunner() + result = runner.invoke(cli) + + assert result.exit_code == 2 + assert 'Missing option "--target"' in result.output + + +@pytest.mark.parametrize( + "args, env, call", + [ + (["--target", "foo"], {}, pretend.call("foo", None, "http")), + ([], {"FUNCTION_TARGET": "foo"}, pretend.call("foo", None, "http")), + ( + ["--target", "foo", "--source", "/path/to/source.py"], + {}, + pretend.call("foo", "/path/to/source.py", "http"), + ), + ( + [], + {"FUNCTION_TARGET": "foo", "FUNCTION_SOURCE": "/path/to/source.py"}, + pretend.call("foo", "/path/to/source.py", "http"), + ), + ( + ["--target", "foo", "--signature_type", "event"], + {}, + pretend.call("foo", None, "event"), + ), + ( + [], + {"FUNCTION_TARGET": "foo", "FUNCTION_SIGNATURE_TYPE": "event"}, + pretend.call("foo", None, "event"), + ), + ], +) +def test_cli_arguments(create_app, args, env, call): + runner = CliRunner(env=env) + result = runner.invoke(cli, args) + + if result.output: + print(result.output) + + assert result.exit_code == 0 + assert create_app.calls == [call] diff --git a/tests/test_functions.py b/tests/test_functions.py new file mode 100644 index 00000000..4c964893 --- /dev/null +++ b/tests/test_functions.py @@ -0,0 +1,358 @@ +# 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 pathlib +import re +import time + +import pytest + +from functions_framework import create_app, exceptions + +TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "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_http_function_executes_success(): + source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.post("/my_path", json={"mode": "SUCCESS"}) + assert resp.status_code == 200 + assert resp.data == b"success" + + +def test_http_function_executes_failure(): + source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("/", json={"mode": "FAILURE"}) + assert resp.status_code == 400 + assert resp.data == b"failure" + + +def test_http_function_executes_throw(): + source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.put("/", json={"mode": "THROW"}) + assert resp.status_code == 500 + + +def test_http_function_request_url_empty_path(): + source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("", json={"mode": "url"}) + assert resp.status_code == 308 + assert resp.location == "http://localhost/" + + +def test_http_function_request_url_slash(): + source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("/", json={"mode": "url"}) + assert resp.status_code == 200 + assert resp.data == b"http://localhost/" + + +def test_http_function_rquest_url_path(): + source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("/my_path", json={"mode": "url"}) + assert resp.status_code == 200 + assert resp.data == b"http://localhost/my_path" + + +def test_http_function_request_path_slash(): + source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("/", json={"mode": "path"}) + assert resp.status_code == 200 + assert resp.data == b"/" + + +def test_http_function_request_path_path(): + source = TEST_FUNCTIONS_DIR / "http_request_check" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("/my_path", json={"mode": "path"}) + assert resp.status_code == 200 + assert resp.data == b"/my_path" + + +def test_http_function_check_env_function_target(): + source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.post("/", json={"mode": "FUNCTION_TARGET"}) + assert resp.status_code == 200 + assert resp.data == b"function" + + +def test_http_function_check_env_function_signature_type(): + source = TEST_FUNCTIONS_DIR / "http_check_env" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.post("/", json={"mode": "FUNCTION_SIGNATURE_TYPE"}) + assert resp.status_code == 200 + assert resp.data == b"http" + + +def test_http_function_execution_time(): + source = TEST_FUNCTIONS_DIR / "http_trigger_sleep" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + start_time = time.time() + resp = client.get("/", json={"mode": "1000"}) + execution_time_sec = time.time() - start_time + + assert resp.status_code == 200 + 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_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_invalid_function_definition_missing_function_file(): + source = TEST_FUNCTIONS_DIR / "missing_function_file" / "main.py" + target = "functions" + + with pytest.raises(exceptions.MissingSourceException) as excinfo: + create_app(target, source) + + assert re.match( + "File .* that is expected to define function doesn't exist", str(excinfo.value) + ) + + +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_http_function_flask_render_template(): + source = TEST_FUNCTIONS_DIR / "http_flask_render_template" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.post("/", json={"message": "test_message"}) + + assert resp.status_code == 200 + assert resp.data == ( + b"\n\n" + b" \n" + b"

Hello test_message!

\n" + b" \n" + b"" + ) + + +def test_http_function_with_import(): + source = TEST_FUNCTIONS_DIR / "http_with_import" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = client.get("/") + + assert resp.status_code == 200 + assert resp.data == b"Hello" + + +@pytest.mark.parametrize( + "method, data", + [ + ("get", b"GET"), + ("head", b""), # body will be empty + ("post", b"POST"), + ("put", b"PUT"), + ("delete", b"DELETE"), + ("options", b"OPTIONS"), + ("trace", b"TRACE"), + ("patch", b"PATCH"), + ], +) +def test_http_function_all_methods(method, data): + source = TEST_FUNCTIONS_DIR / "http_method_check" / "main.py" + target = "function" + + client = create_app(target, source).test_client() + + resp = getattr(client, method)("/") + + assert resp.status_code == 200 + assert resp.data == data diff --git a/tests/test_functions/background_load_error/main.py b/tests/test_functions/background_load_error/main.py new file mode 100644 index 00000000..d9db3c71 --- /dev/null +++ b/tests/test_functions/background_load_error/main.py @@ -0,0 +1,29 @@ +# 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 in Worker tests of detecting load failure.""" + + +def function(event, context): + """Test function with a syntax error. + + The Worker is expected to detect this error when loading the function, and + return appropriate load response. + + Args: + event: The event data which triggered this background function. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ + # Syntax error: an extra closing parenthesis in the line below. + print('foo')) diff --git a/tests/test_functions/background_missing_dependency/main.py b/tests/test_functions/background_missing_dependency/main.py new file mode 100644 index 00000000..19857092 --- /dev/null +++ b/tests/test_functions/background_missing_dependency/main.py @@ -0,0 +1,32 @@ +# 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 in Worker tests of detecting missing dependency.""" +import nonexistentpackage + + +def function(event, context): + """Test function which uses a package which has not been provided. + + The packaged imported above does not exist. Therefore, this import should + fail, the Worker should detect this error, and return appropriate load + response. + + Args: + event: The event data which triggered this background function. + context (google.cloud.functions.Context): The Cloud Functions event context. + """ + del event + del context + nonexistentpackage.wontwork("This function isn't expected to work.") diff --git a/tests/test_functions/background_multiple_entry_points/main.py b/tests/test_functions/background_multiple_entry_points/main.py new file mode 100644 index 00000000..9d976570 --- /dev/null +++ b/tests/test_functions/background_multiple_entry_points/main.py @@ -0,0 +1,71 @@ +# 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. + +"""Functions used in Worker tests of handling multiple entry points.""" + + +def fun(name, event): + """Test function implementation. + + 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: + name: Entry point function which called this helper function. + event: The event which triggered this background function. Must contain + entries for 'value' and 'filename' keys in the data dictionary. + """ + filename = event["filename"] + value = event["value"] + f = open(filename, "w") + f.write('{{"entryPoint": "{}", "value": "{}"}}'.format(name, value)) + f.close() + + +def myFunctionFoo( + event, context +): # Used in test, pylint: disable=invalid-name,unused-argument + """Test function at entry point myFunctionFoo. + + Loaded in a test which verifies entry point handling in a file with multiple + entry points. + + 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. + """ + fun("myFunctionFoo", event) + + +def myFunctionBar( + event, context +): # Used in test, pylint: disable=invalid-name,unused-argument + """Test function at entry point myFunctionBar. + + Loaded in a test which verifies entry point handling in a file with multiple + entry points. + + 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. + """ + fun("myFunctionBar", event) + + +# Used in a test which loads an existing identifier which is not a function. +notAFunction = 42 # Used in test, pylint: disable=invalid-name diff --git a/tests/test_functions/background_trigger/main.py b/tests/test_functions/background_trigger/main.py new file mode 100644 index 00000000..75cc3303 --- /dev/null +++ b/tests/test_functions/background_trigger/main.py @@ -0,0 +1,36 @@ +# 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 in Worker tests of handling background functions.""" + + +def function( + event, context +): # Required by function definition pylint: disable=unused-argument + """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() diff --git a/tests/test_functions/http_check_env/main.py b/tests/test_functions/http_check_env/main.py new file mode 100644 index 00000000..84859634 --- /dev/null +++ b/tests/test_functions/http_check_env/main.py @@ -0,0 +1,35 @@ +# 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 in Worker tests of environment variables setup.""" +import os + +X_GOOGLE_FUNCTION_NAME = "gcf-function" +X_GOOGLE_ENTRY_POINT = "function" +HOME = "/tmp" + + +def function(request): + """Test function which returns the requested environment variable value. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested environment variable in the 'mode' field in JSON document + in request body. + + Returns: + Value of the requested environment variable. + """ + name = request.get_json().get("mode") + return os.environ[name] diff --git a/tests/test_functions/http_flask_render_template/main.py b/tests/test_functions/http_flask_render_template/main.py new file mode 100644 index 00000000..413f0c9c --- /dev/null +++ b/tests/test_functions/http_flask_render_template/main.py @@ -0,0 +1,43 @@ +# 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 in Worker tests of handling HTTP functions.""" + +from flask import render_template + + +def function(request): + """Test HTTP function whose behavior depends on the given mode. + + The function returns a success, a failure, or throws an exception, depending + on the given mode. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested mode in the 'mode' field in JSON document in request + body. + + Returns: + Value and status code defined for the given mode. + + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ + if request.args and "message" in request.args: + message = request.args.get("message") + elif request.get_json() and "message" in request.get_json(): + message = request.get_json()["message"] + else: + message = "Hello World!" + return render_template("hello.html", name=message) diff --git a/tests/test_functions/http_flask_render_template/templates/hello.html b/tests/test_functions/http_flask_render_template/templates/hello.html new file mode 100644 index 00000000..c8f65856 --- /dev/null +++ b/tests/test_functions/http_flask_render_template/templates/hello.html @@ -0,0 +1,6 @@ + + + +

Hello {{ name }}!

+ + diff --git a/tests/test_functions/http_method_check/main.py b/tests/test_functions/http_method_check/main.py new file mode 100644 index 00000000..0782b616 --- /dev/null +++ b/tests/test_functions/http_method_check/main.py @@ -0,0 +1,27 @@ +# 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 in Worker tests of handling HTTP functions.""" + + +def function(request): + """Test HTTP function which returns the method it was called with + + Args: + request: The HTTP request which triggered this function. + + Returns: + The HTTP method which was used to call this function + """ + return request.method diff --git a/tests/test_functions/http_request_check/main.py b/tests/test_functions/http_request_check/main.py new file mode 100644 index 00000000..636069d8 --- /dev/null +++ b/tests/test_functions/http_request_check/main.py @@ -0,0 +1,39 @@ +# 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 in Worker tests of HTTP request contents.""" + + +def function(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 + elif mode == "url": + return request.url + else: + return "invalid request", 400 diff --git a/tests/test_functions/http_trigger/main.py b/tests/test_functions/http_trigger/main.py new file mode 100644 index 00000000..ca207a48 --- /dev/null +++ b/tests/test_functions/http_trigger/main.py @@ -0,0 +1,46 @@ +# 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 in Worker tests of handling HTTP functions.""" + +import flask + + +def function(request): + """Test HTTP function whose behavior depends on the given mode. + + The function returns a success, a failure, or throws an exception, depending + on the given mode. + + Args: + request: The HTTP request which triggered this function. Must contain name + of the requested mode in the 'mode' field in JSON document in request + body. + + Returns: + Value and status code defined for the given mode. + + Raises: + Exception: Thrown when requested in the incoming mode specification. + """ + mode = request.get_json().get("mode") + print("Mode: " + mode) # pylint: disable=superfluous-parens + if mode == "SUCCESS": + return "success", 200 + elif mode == "FAILURE": + return flask.abort(flask.Response("failure", 400)) + elif mode == "THROW": + raise Exception("omg") + else: + return "invalid request", 400 diff --git a/tests/test_functions/http_trigger_sleep/main.py b/tests/test_functions/http_trigger_sleep/main.py new file mode 100644 index 00000000..46203a73 --- /dev/null +++ b/tests/test_functions/http_trigger_sleep/main.py @@ -0,0 +1,32 @@ +# 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 in Worker tests of function execution time.""" +import time + + +def function(request): + """Test function which sleeps for the given number of seconds. + + The test verifies that it gets the response from the function only after the + given number of seconds. + + Args: + request: The HTTP request which triggered this function. Must contain the + requested number of seconds in the 'mode' field in JSON document in + request body. + """ + sleep_sec = int(request.get_json().get("mode")) / 1000.0 + time.sleep(sleep_sec) + return "OK" diff --git a/tests/test_functions/http_with_import/foo.py b/tests/test_functions/http_with_import/foo.py new file mode 100644 index 00000000..e73ebcea --- /dev/null +++ b/tests/test_functions/http_with_import/foo.py @@ -0,0 +1,15 @@ +# 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. + +bar = "Hello" diff --git a/tests/test_functions/http_with_import/main.py b/tests/test_functions/http_with_import/main.py new file mode 100644 index 00000000..f65996da --- /dev/null +++ b/tests/test_functions/http_with_import/main.py @@ -0,0 +1,29 @@ +# 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 in Worker tests of handling HTTP functions.""" + +from foo import bar + + +def function(request): + """Test HTTP function which imports from another file + + Args: + request: The HTTP request which triggered this function. + + Returns: + The imported return value and status code defined for the given mode. + """ + return bar diff --git a/tests/test_functions/missing_function_file/dummy_file b/tests/test_functions/missing_function_file/dummy_file new file mode 100644 index 00000000..195ebe35 --- /dev/null +++ b/tests/test_functions/missing_function_file/dummy_file @@ -0,0 +1 @@ +This is not a file with user's function. diff --git a/tests/test_view_functions.py b/tests/test_view_functions.py new file mode 100644 index 00000000..51dad087 --- /dev/null +++ b/tests/test_view_functions.py @@ -0,0 +1,125 @@ +# 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 pretend + +import functions_framework + + +def test_http_view_func_wrapper(): + function = pretend.call_recorder(lambda request: "Hello") + request_object = pretend.stub() + local_proxy = pretend.stub(_get_current_object=lambda: request_object) + + view_func = functions_framework._http_view_func_wrapper(function, local_proxy) + view_func("/some/path") + + assert function.calls == [pretend.call(request_object)] + + +def test_event_view_func_wrapper(monkeypatch): + data = pretend.stub() + json = { + "context": { + "eventId": "some-eventId", + "timestamp": "some-timestamp", + "eventType": "some-eventType", + "resource": "some-resource", + }, + "data": data, + } + request = pretend.stub(headers={}, get_json=lambda: json) + + 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_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 = { + "eventId": "some-eventId", + "timestamp": "some-timestamp", + "eventType": "some-eventType", + "resource": "some-resource", + "data": data, + } + request = pretend.stub(headers={}, get_json=lambda: json) + + 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", + ) + ] diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..dd616b03 --- /dev/null +++ b/tox.ini @@ -0,0 +1,26 @@ +[tox] +envlist = py{35,36,37,38},lint + +[testenv] +basepython = + py35: python3.5 + py36: python3.6 + py37: python3.7 + py38: python3.8 +deps = + pytest + pretend +commands = + pytest tests {posargs} + +[testenv:lint] +basepython=python3 +deps = + black + twine + isort +commands = + black --check src tests setup.py conftest.py --exclude tests/test_functions/background_load_error/main.py + isort -rc -c src tests setup.py conftest.py + python setup.py --quiet sdist bdist_wheel + twine check dist/*