Skip to content

Commit e16ea67

Browse files
authored
Use Gunicorn for production server (#36)
* Use Gunicorn for production server * Test for coverage * Add some more tests * Update examples * Prerelease 1.4.0rc1 * Update docs * Add upper bound on gunicorn version * Don't need this as a fixture anymore
1 parent d934ba0 commit e16ea67

File tree

14 files changed

+306
-41
lines changed

14 files changed

+306
-41
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@
44
__pycache__/
55
build/
66
dist/
7+
.coverage

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
66

77
## [Unreleased]
88

9+
## [1.4.0] - 2020-05-06
10+
- Use gunicorn as a production HTTP server
11+
912
## [1.3.0] - 2020-04-13
1013
- Add support for running `python -m functions_framework` ([#31])
1114
- Move `functions_framework.cli.cli` to `functions_framework._cli._cli`

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pip install functions-framework
4545
Or, for deployment, add the Functions Framework to your `requirements.txt` file:
4646

4747
```
48-
functions-framework==1.3.0
48+
functions-framework==1.4.0rc1
4949
```
5050

5151
# Quickstart: Hello, World on your local machine
@@ -84,7 +84,7 @@ pip install functions-framework
8484
Use the `functions-framework` command to start the built-in local development server:
8585

8686
```sh
87-
functions-framework --target hello
87+
functions-framework --target hello --debug
8888
* Serving Flask app "hello" (lazy loading)
8989
* Environment: production
9090
WARNING: This is a development server. Do not use it in a production deployment.

examples/cloud_run_event/Dockerfile

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,5 @@ COPY . .
1111
RUN pip install gunicorn functions-framework
1212
RUN pip install -r requirements.txt
1313

14-
# Run the web service on container startup. Here we use the gunicorn
15-
# webserver, with one worker process and 8 threads.
16-
# For environments with multiple CPU cores, increase the number of workers
17-
# to be equal to the cores available.
18-
CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 -e FUNCTION_TARGET=hello -e FUNCTION_SIGNATURE_TYPE=event functions_framework:app
14+
# Run the web service on container startup.
15+
CMD exec functions-framework --target=hello --signature_type=event

examples/cloud_run_http/Dockerfile

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,5 @@ COPY . .
1111
RUN pip install gunicorn functions-framework
1212
RUN pip install -r requirements.txt
1313

14-
# Run the web service on container startup. Here we use the gunicorn
15-
# webserver, with one worker process and 8 threads.
16-
# For environments with multiple CPU cores, increase the number of workers
17-
# to be equal to the cores available.
18-
CMD exec gunicorn --bind :$PORT --workers 1 --threads 8 -e FUNCTION_TARGET=hello functions_framework:app
14+
# Run the web service on container startup.
15+
CMD exec functions-framework --target=hello

setup.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626
setup(
2727
name="functions-framework",
28-
version="1.3.0",
28+
version="1.4.0rc1",
2929
description="An open source FaaS (Function as a service) framework for writing portable Python functions -- brought to you by the Google Cloud Functions team.",
3030
long_description=long_description,
3131
long_description_content_type="text/markdown",
@@ -47,7 +47,12 @@
4747
namespace_packages=["google", "google.cloud"],
4848
package_dir={"": "src"},
4949
python_requires=">=3.5, <4",
50-
install_requires=["flask>=1.0,<2.0", "click>=7.0,<8.0", "watchdog>=0.10.0"],
50+
install_requires=[
51+
"flask>=1.0,<2.0",
52+
"click>=7.0,<8.0",
53+
"watchdog>=0.10.0",
54+
"gunicorn>=19.2.0,<21.0",
55+
],
5156
extras_require={"test": ["pytest", "tox"]},
5257
entry_points={
5358
"console_scripts": [

src/functions_framework/__main__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
1+
# Copyright 2020 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
115
from functions_framework._cli import _cli
216

317
_cli(prog_name="python -m functions_framework")

src/functions_framework/_cli.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import click
1818

1919
from functions_framework import create_app
20+
from functions_framework._http import create_server
2021

2122

2223
@click.command()
@@ -38,5 +39,9 @@ def _cli(target, source, signature_type, host, port, debug, dry_run):
3839
click.echo("Function: {}".format(target))
3940
click.echo("URL: http://{}:{}/".format(host, port))
4041
click.echo("Dry run successful, shutting down.")
41-
else:
42+
elif debug:
43+
# Run with Flask's development WSGI server
4244
app.run(host, port, debug)
45+
else:
46+
# Run with Gunicorn's production WSGI server
47+
create_server(app).run(host, port)

src/functions_framework/_http.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
# Copyright 2020 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import gunicorn.app.base
16+
17+
18+
class GunicornApplication(gunicorn.app.base.BaseApplication):
19+
def __init__(self, app, host, port, **options):
20+
self.options = {
21+
"bind": "%s:%s" % (host, port),
22+
"workers": 1,
23+
"threads": 8,
24+
"timeout": 0,
25+
}
26+
self.options.update(options)
27+
self.app = app
28+
super().__init__()
29+
30+
def load_config(self):
31+
for key, value in self.options.items():
32+
self.cfg.set(key, value)
33+
34+
def load(self):
35+
return self.app
36+
37+
38+
class HTTPServer:
39+
def __init__(self, app, server_class, **options):
40+
self.app = app
41+
self.server_class = server_class
42+
self.options = options
43+
44+
def run(self, host, port):
45+
http_server = self.server_class(self.app, host, port, **self.options)
46+
http_server.run()
47+
48+
49+
def create_server(wsgi_app, **options):
50+
return HTTPServer(wsgi_app, server_class=GunicornApplication, **options)

tests/test_cli.py

Lines changed: 49 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,6 @@
2222
from functions_framework._cli import _cli
2323

2424

25-
@pytest.fixture
26-
def run():
27-
return pretend.call_recorder(lambda *a, **kw: None)
28-
29-
30-
@pytest.fixture
31-
def create_app(monkeypatch, run):
32-
create_app = pretend.call_recorder(lambda *a, **kw: pretend.stub(run=run))
33-
monkeypatch.setattr(functions_framework._cli, "create_app", create_app)
34-
return create_app
35-
36-
3725
def test_cli_no_arguments():
3826
runner = CliRunner()
3927
result = runner.invoke(_cli)
@@ -43,63 +31,101 @@ def test_cli_no_arguments():
4331

4432

4533
@pytest.mark.parametrize(
46-
"args, env, create_app_calls, run_calls",
34+
"args, env, create_app_calls, app_run_calls, wsgi_server_run_calls",
4735
[
4836
(
4937
["--target", "foo"],
5038
{},
5139
[pretend.call("foo", None, "http")],
52-
[pretend.call("0.0.0.0", 8080, False)],
40+
[],
41+
[pretend.call("0.0.0.0", 8080)],
5342
),
5443
(
5544
[],
5645
{"FUNCTION_TARGET": "foo"},
5746
[pretend.call("foo", None, "http")],
58-
[pretend.call("0.0.0.0", 8080, False)],
47+
[],
48+
[pretend.call("0.0.0.0", 8080)],
5949
),
6050
(
6151
["--target", "foo", "--source", "/path/to/source.py"],
6252
{},
6353
[pretend.call("foo", "/path/to/source.py", "http")],
64-
[pretend.call("0.0.0.0", 8080, False)],
54+
[],
55+
[pretend.call("0.0.0.0", 8080)],
6556
),
6657
(
6758
[],
6859
{"FUNCTION_TARGET": "foo", "FUNCTION_SOURCE": "/path/to/source.py"},
6960
[pretend.call("foo", "/path/to/source.py", "http")],
70-
[pretend.call("0.0.0.0", 8080, False)],
61+
[],
62+
[pretend.call("0.0.0.0", 8080)],
7163
),
7264
(
7365
["--target", "foo", "--signature-type", "event"],
7466
{},
7567
[pretend.call("foo", None, "event")],
76-
[pretend.call("0.0.0.0", 8080, False)],
68+
[],
69+
[pretend.call("0.0.0.0", 8080)],
7770
),
7871
(
7972
[],
8073
{"FUNCTION_TARGET": "foo", "FUNCTION_SIGNATURE_TYPE": "event"},
8174
[pretend.call("foo", None, "event")],
82-
[pretend.call("0.0.0.0", 8080, False)],
75+
[],
76+
[pretend.call("0.0.0.0", 8080)],
77+
),
78+
(
79+
["--target", "foo", "--dry-run"],
80+
{},
81+
[pretend.call("foo", None, "http")],
82+
[],
83+
[],
8384
),
84-
(["--target", "foo", "--dry-run"], {}, [pretend.call("foo", None, "http")], []),
8585
(
8686
[],
8787
{"FUNCTION_TARGET": "foo", "DRY_RUN": "True"},
8888
[pretend.call("foo", None, "http")],
8989
[],
90+
[],
9091
),
9192
(
9293
["--target", "foo", "--host", "127.0.0.1"],
9394
{},
9495
[pretend.call("foo", None, "http")],
95-
[pretend.call("127.0.0.1", 8080, False)],
96+
[],
97+
[pretend.call("127.0.0.1", 8080)],
98+
),
99+
(
100+
["--target", "foo", "--debug"],
101+
{},
102+
[pretend.call("foo", None, "http")],
103+
[pretend.call("0.0.0.0", 8080, True)],
104+
[],
105+
),
106+
(
107+
[],
108+
{"FUNCTION_TARGET": "foo", "DEBUG": "True"},
109+
[pretend.call("foo", None, "http")],
110+
[pretend.call("0.0.0.0", 8080, True)],
111+
[],
96112
),
97113
],
98114
)
99-
def test_cli_arguments(create_app, run, args, env, create_app_calls, run_calls):
115+
def test_cli(
116+
monkeypatch, args, env, create_app_calls, app_run_calls, wsgi_server_run_calls,
117+
):
118+
wsgi_server = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
119+
wsgi_app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None))
120+
create_app = pretend.call_recorder(lambda *a, **kw: wsgi_app)
121+
monkeypatch.setattr(functions_framework._cli, "create_app", create_app)
122+
create_server = pretend.call_recorder(lambda *a, **kw: wsgi_server)
123+
monkeypatch.setattr(functions_framework._cli, "create_server", create_server)
124+
100125
runner = CliRunner(env=env)
101126
result = runner.invoke(_cli, args)
102127

103128
assert result.exit_code == 0
104129
assert create_app.calls == create_app_calls
105-
assert run.calls == run_calls
130+
assert wsgi_app.run.calls == app_run_calls
131+
assert wsgi_server.run.calls == wsgi_server_run_calls

tests/test_functions.py

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
import re
1717
import time
1818

19+
import pretend
1920
import pytest
2021

21-
from functions_framework import create_app, exceptions
22+
import functions_framework
23+
24+
from functions_framework import LazyWSGIApp, create_app, exceptions
2225

2326
TEST_FUNCTIONS_DIR = pathlib.Path.cwd() / "tests" / "test_functions"
2427

@@ -323,6 +326,24 @@ def test_invalid_function_definition_missing_dependency():
323326
assert "No module named 'nonexistentpackage'" in str(excinfo.value)
324327

325328

329+
def test_invalid_configuration():
330+
with pytest.raises(exceptions.InvalidConfigurationException) as excinfo:
331+
create_app(None, None, None)
332+
333+
assert (
334+
"Target is not specified (FUNCTION_TARGET environment variable not set)"
335+
== str(excinfo.value)
336+
)
337+
338+
339+
def test_invalid_signature_type():
340+
source = TEST_FUNCTIONS_DIR / "http_trigger" / "main.py"
341+
target = "function"
342+
343+
with pytest.raises(exceptions.FunctionsFrameworkException) as excinfo:
344+
create_app(target, source, "invalid_signature_type")
345+
346+
326347
def test_http_function_flask_render_template():
327348
source = TEST_FUNCTIONS_DIR / "http_flask_render_template" / "main.py"
328349
target = "function"
@@ -389,3 +410,38 @@ def test_error_paths(path):
389410

390411
assert resp.status_code == 404
391412
assert b"Not Found" in resp.data
413+
414+
415+
@pytest.mark.parametrize(
416+
"target, source, signature_type",
417+
[(None, None, None), (pretend.stub(), pretend.stub(), pretend.stub()),],
418+
)
419+
def test_lazy_wsgi_app(monkeypatch, target, source, signature_type):
420+
actual_app_stub = pretend.stub()
421+
wsgi_app = pretend.call_recorder(lambda *a, **kw: actual_app_stub)
422+
create_app = pretend.call_recorder(lambda *a: wsgi_app)
423+
monkeypatch.setattr(functions_framework, "create_app", create_app)
424+
425+
# Test that it's lazy
426+
lazy_app = LazyWSGIApp(target, source, signature_type)
427+
428+
assert lazy_app.app == None
429+
430+
args = [pretend.stub(), pretend.stub()]
431+
kwargs = {"a": pretend.stub(), "b": pretend.stub()}
432+
433+
# Test that it's initialized when called
434+
app = lazy_app(*args, **kwargs)
435+
436+
assert app == actual_app_stub
437+
assert create_app.calls == [pretend.call(target, source, signature_type)]
438+
assert wsgi_app.calls == [pretend.call(*args, **kwargs)]
439+
440+
# Test that it's only initialized once
441+
app = lazy_app(*args, **kwargs)
442+
443+
assert app == actual_app_stub
444+
assert wsgi_app.calls == [
445+
pretend.call(*args, **kwargs),
446+
pretend.call(*args, **kwargs),
447+
]

0 commit comments

Comments
 (0)