Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use Gunicorn for production server #36

Merged
merged 8 commits into from
May 6, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
__pycache__/
build/
dist/
.coverage
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
7 changes: 2 additions & 5 deletions examples/cloud_run_event/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
7 changes: 2 additions & 5 deletions examples/cloud_run_http/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 7 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": [
Expand Down
14 changes: 14 additions & 0 deletions src/functions_framework/__main__.py
Original file line number Diff line number Diff line change
@@ -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")
7 changes: 6 additions & 1 deletion src/functions_framework/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import click

from functions_framework import create_app
from functions_framework._http import create_server


@click.command()
Expand All @@ -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)
50 changes: 50 additions & 0 deletions src/functions_framework/_http.py
Original file line number Diff line number Diff line change
@@ -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)
72 changes: 49 additions & 23 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
58 changes: 57 additions & 1 deletion tests/test_functions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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),
]
Loading