diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 00000000..621ddf61 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,35 @@ +name: CI + +on: [push, pull_request] + +jobs: + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install tox + run: python -m pip install tox + - name: Run linting + run: python -m tox -e lint + + test: + strategy: + matrix: + python: [3.6, 3.7, 3.8] + platform: [ubuntu-latest, macos-latest, windows-latest] + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v2 + - name: Setup Python + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python }} + - name: Install tox + run: python -m pip install tox + - name: Run tests + run: python -m tox -e py-${{ matrix.platform }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 82edfebe..00000000 --- a/.travis.yml +++ /dev/null @@ -1,18 +0,0 @@ -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/CHANGELOG.md b/CHANGELOG.md index 3e1e25de..cde50b76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- Fix Windows support ([#38]) ## [1.4.0] - 2020-05-06 - Use gunicorn as a production HTTP server @@ -50,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [1.0.1]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.0.1 [1.0.0]: https://github.com/GoogleCloudPlatform/functions-framework-python/releases/tag/v1.0.0 +[#38]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/38 [#33]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/33 [#31]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/31 [#20]: https://github.com/GoogleCloudPlatform/functions-framework-python/pull/20 diff --git a/setup.py b/setup.py index d5c3eba0..9b17ba46 100644 --- a/setup.py +++ b/setup.py @@ -51,7 +51,7 @@ "flask>=1.0,<2.0", "click>=7.0,<8.0", "watchdog>=0.10.0", - "gunicorn>=19.2.0,<21.0", + "gunicorn>=19.2.0,<21.0; platform_system!='Windows'", ], extras_require={"test": ["pytest", "tox"]}, entry_points={ diff --git a/src/functions_framework/_cli.py b/src/functions_framework/_cli.py index ee5a41bd..4fe6e427 100644 --- a/src/functions_framework/_cli.py +++ b/src/functions_framework/_cli.py @@ -39,9 +39,5 @@ 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.") - 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) + create_server(app, debug).run(host, port) diff --git a/src/functions_framework/_http/__init__.py b/src/functions_framework/_http/__init__.py new file mode 100644 index 00000000..0e532780 --- /dev/null +++ b/src/functions_framework/_http/__init__.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. + +from functions_framework._http.flask import FlaskApplication + + +class HTTPServer: + def __init__(self, app, debug, **options): + self.app = app + self.options = options + + if debug: + self.server_class = FlaskApplication + else: + try: + from functions_framework._http.gunicorn import GunicornApplication + + self.server_class = GunicornApplication + except ImportError as e: + self.server_class = FlaskApplication + + def run(self, host, port): + http_server = self.server_class(self.app, host, port, **self.options) + http_server.run() + + +def create_server(wsgi_app, debug, **options): + return HTTPServer(wsgi_app, debug, **options) diff --git a/src/functions_framework/_http/flask.py b/src/functions_framework/_http/flask.py new file mode 100644 index 00000000..270390e7 --- /dev/null +++ b/src/functions_framework/_http/flask.py @@ -0,0 +1,24 @@ +# 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 FlaskApplication: + def __init__(self, app, host, port, **options): + self.app = app + self.host = host + self.port = port + self.options = options + + def run(self): + self.app.run(self.host, self.port, debug=True, **self.options) diff --git a/src/functions_framework/_http.py b/src/functions_framework/_http/gunicorn.py similarity index 72% rename from src/functions_framework/_http.py rename to src/functions_framework/_http/gunicorn.py index 279a95bc..fd0b0a3a 100644 --- a/src/functions_framework/_http.py +++ b/src/functions_framework/_http/gunicorn.py @@ -33,18 +33,3 @@ def load_config(self): 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 93194d0d..aa4a901e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -31,48 +31,42 @@ def test_cli_no_arguments(): @pytest.mark.parametrize( - "args, env, create_app_calls, app_run_calls, wsgi_server_run_calls", + "args, env, create_app_calls, run_calls", [ ( ["--target", "foo"], {}, [pretend.call("foo", None, "http")], - [], [pretend.call("0.0.0.0", 8080)], ), ( [], {"FUNCTION_TARGET": "foo"}, [pretend.call("foo", None, "http")], - [], [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)], ), ( [], {"FUNCTION_TARGET": "foo", "FUNCTION_SOURCE": "/path/to/source.py"}, [pretend.call("foo", "/path/to/source.py", "http")], - [], [pretend.call("0.0.0.0", 8080)], ), ( ["--target", "foo", "--signature-type", "event"], {}, [pretend.call("foo", None, "event")], - [], [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)], ), ( @@ -80,41 +74,34 @@ def test_cli_no_arguments(): {}, [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)], ), ( ["--target", "foo", "--debug"], {}, [pretend.call("foo", None, "http")], - [pretend.call("0.0.0.0", 8080, True)], - [], + [pretend.call("0.0.0.0", 8080)], ), ( [], {"FUNCTION_TARGET": "foo", "DEBUG": "True"}, [pretend.call("foo", None, "http")], - [pretend.call("0.0.0.0", 8080, True)], - [], + [pretend.call("0.0.0.0", 8080)], ), ], ) -def test_cli( - monkeypatch, args, env, create_app_calls, app_run_calls, wsgi_server_run_calls, -): +def test_cli(monkeypatch, args, env, create_app_calls, 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) @@ -127,5 +114,4 @@ def test_cli( assert result.exit_code == 0 assert create_app.calls == create_app_calls - assert wsgi_app.run.calls == app_run_calls - assert wsgi_server.run.calls == wsgi_server_run_calls + assert wsgi_server.run.calls == run_calls diff --git a/tests/test_http.py b/tests/test_http.py index 77499e98..37fbf2cf 100644 --- a/tests/test_http.py +++ b/tests/test_http.py @@ -12,42 +12,62 @@ # See the License for the specific language governing permissions and # limitations under the License. +import platform +import sys + import pretend import pytest import functions_framework._http -stub = pretend.stub() - -def test_create_server(monkeypatch): +@pytest.mark.parametrize("debug", [True, False]) +def test_create_server(monkeypatch, debug): 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) + functions_framework._http.create_server(wsgi_app, debug, **options) - assert httpserver.calls == [ - pretend.call( - wsgi_app, - server_class=functions_framework._http.GunicornApplication, - **options - ) - ] + assert httpserver.calls == [pretend.call(wsgi_app, debug, **options)] -def test_httpserver(): +@pytest.mark.parametrize( + "debug, gunicorn_missing, expected", + [ + (True, False, "flask"), + (False, False, "flask" if platform.system() == "Windows" else "gunicorn"), + (True, True, "flask"), + (False, True, "flask"), + ], +) +def test_httpserver(monkeypatch, debug, gunicorn_missing, expected): app = pretend.stub() http_server = pretend.stub(run=pretend.call_recorder(lambda: None)) - server_class = pretend.call_recorder(lambda *a, **kw: http_server) + server_classes = { + "flask": pretend.call_recorder(lambda *a, **kw: http_server), + "gunicorn": pretend.call_recorder(lambda *a, **kw: http_server), + } options = {"a": pretend.stub(), "b": pretend.stub()} - wrapper = functions_framework._http.HTTPServer(app, server_class, **options) + monkeypatch.setattr( + functions_framework._http, "FlaskApplication", server_classes["flask"], + ) + if gunicorn_missing or platform.system() == "Windows": + monkeypatch.setitem(sys.modules, "functions_framework._http.gunicorn", None) + else: + from functions_framework._http import gunicorn + + monkeypatch.setattr( + gunicorn, "GunicornApplication", server_classes["gunicorn"], + ) + + wrapper = functions_framework._http.HTTPServer(app, debug, **options) assert wrapper.app == app - assert wrapper.server_class == server_class + assert wrapper.server_class == server_classes[expected] assert wrapper.options == options host = pretend.stub() @@ -55,17 +75,20 @@ def test_httpserver(): wrapper.run(host, port) - assert server_class.calls == [pretend.call(app, host, port, **options)] + assert wrapper.server_class.calls == [pretend.call(app, host, port, **options)] assert http_server.run.calls == [pretend.call()] -def test_gunicorn_application(monkeypatch): +@pytest.mark.skipif("platform.system() == 'Windows'") +def test_gunicorn_application(): app = pretend.stub() host = "1.2.3.4" port = "1234" options = {} - gunicorn_app = functions_framework._http.GunicornApplication( + import functions_framework._http.gunicorn + + gunicorn_app = functions_framework._http.gunicorn.GunicornApplication( app, host, port, **options ) @@ -82,3 +105,25 @@ def test_gunicorn_application(monkeypatch): assert gunicorn_app.cfg.threads == 8 assert gunicorn_app.cfg.timeout == 0 assert gunicorn_app.load() == app + + +def test_flask_application(): + app = pretend.stub(run=pretend.call_recorder(lambda *a, **kw: None)) + host = pretend.stub() + port = pretend.stub() + options = {"a": pretend.stub(), "b": pretend.stub()} + + flask_app = functions_framework._http.flask.FlaskApplication( + app, host, port, **options + ) + + assert flask_app.app == app + assert flask_app.host == host + assert flask_app.port == port + assert flask_app.options == options + + flask_app.run() + + assert app.run.calls == [ + pretend.call(host, port, debug=True, a=options["a"], b=options["b"]), + ] diff --git a/tox.ini b/tox.ini index 8620210d..f5b65064 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,15 @@ [tox] -envlist = py{35,36,37,38},lint +envlist = py{35,36,37,38}-{ubuntu-latest,macos-latest,windows-latest},lint [testenv] usedevelop = true -basepython = - py35: python3.5 - py36: python3.6 - py37: python3.7 - py38: python3.8 deps = pytest-cov pretend -commands = - pytest --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 {posargs} +setenv = + PYTESTARGS = --cov=functions_framework --cov-branch --cov-report term-missing --cov-fail-under=100 + windows-latest: PYTESTARGS = +commands = pytest {env:PYTESTARGS} {posargs} [testenv:lint] basepython=python3