diff --git a/.gitignore b/.gitignore index 98e18281..c0305632 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ __pycache__/ build/ dist/ +.coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index ca029a89..3e1e25de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.4.0] - 2020-05-06 +- Use gunicorn as a production HTTP server + ## [1.3.0] - 2020-04-13 - Add support for running `python -m functions_framework` ([#31]) - Move `functions_framework.cli.cli` to `functions_framework._cli._cli` diff --git a/README.md b/README.md index fd982e38..e8872890 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==1.3.0 +functions-framework==1.4.0rc1 ``` # Quickstart: Hello, World on your local machine @@ -84,7 +84,7 @@ pip install functions-framework Use the `functions-framework` command to start the built-in local development server: ```sh -functions-framework --target hello +functions-framework --target hello --debug * Serving Flask app "hello" (lazy loading) * Environment: production WARNING: This is a development server. Do not use it in a production deployment. diff --git a/examples/cloud_run_event/Dockerfile b/examples/cloud_run_event/Dockerfile index d91cdf5f..6b31c042 100644 --- a/examples/cloud_run_event/Dockerfile +++ b/examples/cloud_run_event/Dockerfile @@ -11,8 +11,5 @@ COPY . . 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 -e FUNCTION_SIGNATURE_TYPE=event functions_framework:app +# Run the web service on container startup. +CMD exec functions-framework --target=hello --signature_type=event diff --git a/examples/cloud_run_http/Dockerfile b/examples/cloud_run_http/Dockerfile index b211a229..e8ab5287 100644 --- a/examples/cloud_run_http/Dockerfile +++ b/examples/cloud_run_http/Dockerfile @@ -11,8 +11,5 @@ COPY . . 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 +# Run the web service on container startup. +CMD exec functions-framework --target=hello diff --git a/setup.py b/setup.py index 3a5b6b68..3642852a 100644 --- a/setup.py +++ b/setup.py @@ -25,7 +25,7 @@ setup( name="functions-framework", - version="1.3.0", + version="1.4.0rc1", 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", @@ -47,7 +47,12 @@ 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", "watchdog>=0.10.0"], + install_requires=[ + "flask>=1.0,<2.0", + "click>=7.0,<8.0", + "watchdog>=0.10.0", + "gunicorn>=19.2.0,<21.0", + ], extras_require={"test": ["pytest", "tox"]}, entry_points={ "console_scripts": [ diff --git a/src/functions_framework/__main__.py b/src/functions_framework/__main__.py index 0435041c..5f2e710c 100644 --- a/src/functions_framework/__main__.py +++ b/src/functions_framework/__main__.py @@ -1,3 +1,17 @@ +# 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 functions_framework._cli import _cli _cli(prog_name="python -m functions_framework") diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index 20092228..ee5a41bd 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -17,6 +17,7 @@ import click from functions_framework import create_app +from functions_framework._http import create_server @click.command() @@ -38,5 +39,9 @@ def _cli(target, source, signature_type, host, port, debug, dry_run): click.echo("Function: {}".format(target)) click.echo("URL: http://{}:{}/".format(host, port)) click.echo("Dry run successful, shutting down.") - else: + elif debug: + # Run with Flask's development WSGI server app.run(host, port, debug) + else: + # Run with Gunicorn's production WSGI server + create_server(app).run(host, port) diff --git a/src/functions_framework/_http.py b/src/functions_framework/_http.py new file mode 100644 index 00000000..279a95bc --- /dev/null +++ b/src/functions_framework/_http.py @@ -0,0 +1,50 @@ +# 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 gunicorn.app.base + + +class GunicornApplication(gunicorn.app.base.BaseApplication): + def __init__(self, app, host, port, **options): + self.options = { + "bind": "%s:%s" % (host, port), + "workers": 1, + "threads": 8, + "timeout": 0, + } + self.options.update(options) + self.app = app + super().__init__() + + def load_config(self): + for key, value in self.options.items(): + self.cfg.set(key, value) + + def load(self): + return self.app + + +class HTTPServer: + def __init__(self, app, server_class, **options): + self.app = app + self.server_class = server_class + self.options = options + + def run(self, host, port): + http_server = self.server_class(self.app, host, port, **self.options) + http_server.run() + + +def create_server(wsgi_app, **options): + return HTTPServer(wsgi_app, server_class=GunicornApplication, **options) diff --git a/tests/test_cli.py b/tests/test_cli.py index 7d408d81..93194d0d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -22,18 +22,6 @@ from functions_framework._cli import _cli -@pytest.fixture -def run(): - return pretend.call_recorder(lambda *a, **kw: None) - - -@pytest.fixture -def create_app(monkeypatch, run): - create_app = pretend.call_recorder(lambda *a, **kw: pretend.stub(run=run)) - monkeypatch.setattr(functions_framework._cli, "create_app", create_app) - return create_app - - def test_cli_no_arguments(): runner = CliRunner() result = runner.invoke(_cli) @@ -43,63 +31,101 @@ def test_cli_no_arguments(): @pytest.mark.parametrize( - "args, env, create_app_calls, run_calls", + "args, env, create_app_calls, app_run_calls, wsgi_server_run_calls", [ ( ["--target", "foo"], {}, [pretend.call("foo", None, "http")], - [pretend.call("0.0.0.0", 8080, False)], + [], + [pretend.call("0.0.0.0", 8080)], ), ( [], {"FUNCTION_TARGET": "foo"}, [pretend.call("foo", None, "http")], - [pretend.call("0.0.0.0", 8080, False)], + [], + [pretend.call("0.0.0.0", 8080)], ), ( ["--target", "foo", "--source", "/path/to/source.py"], {}, [pretend.call("foo", "/path/to/source.py", "http")], - [pretend.call("0.0.0.0", 8080, False)], + [], + [pretend.call("0.0.0.0", 8080)], ), ( [], {"FUNCTION_TARGET": "foo", "FUNCTION_SOURCE": "/path/to/source.py"}, [pretend.call("foo", "/path/to/source.py", "http")], - [pretend.call("0.0.0.0", 8080, False)], + [], + [pretend.call("0.0.0.0", 8080)], ), ( ["--target", "foo", "--signature-type", "event"], {}, [pretend.call("foo", None, "event")], - [pretend.call("0.0.0.0", 8080, False)], + [], + [pretend.call("0.0.0.0", 8080)], ), ( [], {"FUNCTION_TARGET": "foo", "FUNCTION_SIGNATURE_TYPE": "event"}, [pretend.call("foo", None, "event")], - [pretend.call("0.0.0.0", 8080, False)], + [], + [pretend.call("0.0.0.0", 8080)], + ), + ( + ["--target", "foo", "--dry-run"], + {}, + [pretend.call("foo", None, "http")], + [], + [], ), - (["--target", "foo", "--dry-run"], {}, [pretend.call("foo", None, "http")], []), ( [], {"FUNCTION_TARGET": "foo", "DRY_RUN": "True"}, [pretend.call("foo", None, "http")], [], + [], ), ( ["--target", "foo", "--host", "127.0.0.1"], {}, [pretend.call("foo", None, "http")], - [pretend.call("127.0.0.1", 8080, False)], + [], + [pretend.call("127.0.0.1", 8080)], + ), + ( + ["--target", "foo", "--debug"], + {}, + [pretend.call("foo", None, "http")], + [pretend.call("0.0.0.0", 8080, True)], + [], + ), + ( + [], + {"FUNCTION_TARGET": "foo", "DEBUG": "True"}, + [pretend.call("foo", None, "http")], + [pretend.call("0.0.0.0", 8080, True)], + [], ), ], ) -def test_cli_arguments(create_app, run, args, env, create_app_calls, run_calls): +def test_cli( + monkeypatch, args, env, create_app_calls, app_run_calls, wsgi_server_run_calls, +): + wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app) + monkeypatch.setattr(functions_framework._cli, "create_app", create_app) + create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server) + monkeypatch.setattr(functions_framework._cli, "create_server", create_server) + runner = CliRunner(env=env) result = runner.invoke(_cli, args) assert result.exit_code == 0 assert create_app.calls == create_app_calls - assert run.calls == run_calls + assert wsgi_app.run.calls == app_run_calls + assert wsgi_server.run.calls == wsgi_server_run_calls diff --git a/tests/test_functions.py b/tests/test_functions.py index 5c7250b8..fece24ca 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -16,9 +16,12 @@ import re import time +import pretend import pytest -from functions_framework import create_app, exceptions +import functions_framework + +from functions_framework import LazyWSGIApp, create_app, exceptions TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions" @@ -323,6 +326,24 @@ def test_invalid_function_definition_missing_dependency(): 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) + + assert ( + "Target is not specified (FUNCTION_TARGET environment variable not set)" + == str(excinfo.value) + ) + + +def test_invalid_signature_type(): + source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py" + target = "function" + + with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo: + create_app(target, source, "invalid_signature_type") + + def test_http_function_flask_render_template(): source = TEST_FUNCTIONS_DIR / "http_flask_render_template" / "main.py" target = "function" @@ -389,3 +410,38 @@ def test_error_paths(path): assert resp.status_code == 404 assert b"Not Found" in resp.data + + +@pytest.mark.parametrize( + "target, source, signature_type", + [(None, None, None), (pretend.stub(), pretend.stub(), pretend.stub()),], +) +def test_lazy_wsgi_app(monkeypatch, target, source, signature_type): + actual_app_stub = pretend.stub() + wsgi_app = pretend.call_recorder(lambda *a, **kw: actual_app_stub) + create_app = pretend.call_recorder(lambda *a: wsgi_app) + monkeypatch.setattr(functions_framework, "create_app", create_app) + + # Test that it's lazy + lazy_app = LazyWSGIApp(target, source, signature_type) + + assert lazy_app.app == None + + args = [pretend.stub(), pretend.stub()] + kwargs = {"a": pretend.stub(), "b": pretend.stub()} + + # Test that it's initialized when called + app = lazy_app(*args, **kwargs) + + assert app == actual_app_stub + assert create_app.calls == [pretend.call(target, source, signature_type)] + assert wsgi_app.calls == [pretend.call(*args, **kwargs)] + + # Test that it's only initialized once + app = lazy_app(*args, **kwargs) + + assert app == actual_app_stub + assert wsgi_app.calls == [ + pretend.call(*args, **kwargs), + pretend.call(*args, **kwargs), + ] diff --git a/tests/test_http.py b/tests/test_http.py new file mode 100644 index 00000000..77499e98 --- /dev/null +++ b/tests/test_http.py @@ -0,0 +1,84 @@ +# 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 + +import functions_framework._http + +stub = pretend.stub() + + +def test_create_server(monkeypatch): + server_stub = pretend.stub() + httpserver = pretend.call_recorder(lambda *a, **kw: server_stub) + monkeypatch.setattr(functions_framework._http, "HTTPServer", httpserver) + wsgi_app = pretend.stub() + options = {"a": pretend.stub(), "b": pretend.stub()} + + functions_framework._http.create_server(wsgi_app, **options) + + assert httpserver.calls == [ + pretend.call( + wsgi_app, + server_class=functions_framework._http.GunicornApplication, + **options + ) + ] + + +def test_httpserver(): + app = pretend.stub() + http_server = pretend.stub(run=pretend.call_recorder(lambda: None)) + server_class = pretend.call_recorder(lambda *a, **kw: http_server) + options = {"a": pretend.stub(), "b": pretend.stub()} + + wrapper = functions_framework._http.HTTPServer(app, server_class, **options) + + assert wrapper.app == app + assert wrapper.server_class == server_class + assert wrapper.options == options + + host = pretend.stub() + port = pretend.stub() + + wrapper.run(host, port) + + assert server_class.calls == [pretend.call(app, host, port, **options)] + assert http_server.run.calls == [pretend.call()] + + +def test_gunicorn_application(monkeypatch): + app = pretend.stub() + host = "1.2.3.4" + port = "1234" + options = {} + + gunicorn_app = functions_framework._http.GunicornApplication( + app, host, port, **options + ) + + assert gunicorn_app.app == app + assert gunicorn_app.options == { + "bind": "%s:%s" % (host, port), + "workers": 1, + "threads": 8, + "timeout": 0, + } + + assert gunicorn_app.cfg.bind == ["1.2.3.4:1234"] + assert gunicorn_app.cfg.workers == 1 + assert gunicorn_app.cfg.threads == 8 + assert gunicorn_app.cfg.timeout == 0 + assert gunicorn_app.load() == app diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 00000000..91df82cf --- /dev/null +++ b/tests/test_main.py @@ -0,0 +1,26 @@ +# 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._cli + + +def test_main(monkeypatch): + _cli = pretend.call_recorder(lambda prog_name: None) + monkeypatch.setattr(functions_framework._cli, "_cli", _cli) + + from functions_framework import __main__ + + assert _cli.calls == [pretend.call(prog_name="python -m functions_framework")] diff --git a/tox.ini b/tox.ini index dd616b03..8620210d 100644 --- a/tox.ini +++ b/tox.ini @@ -2,16 +2,17 @@ envlist = py{35,36,37,38},lint [testenv] +usedevelop = true basepython = py35: python3.5 py36: python3.6 py37: python3.7 py38: python3.8 deps = - pytest + pytest-cov pretend commands = - pytest tests {posargs} + pytest --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 {posargs} [testenv:lint] basepython=python3