From 08935fe182aed49a40a75a939bdf77bb9d7e3877 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Apr 2023 14:05:56 +0200 Subject: [PATCH 1/5] build(deps-dev): bump pytest from 7.3.0 to 7.3.1 (#29) Bumps [pytest](https://github.com/pytest-dev/pytest) from 7.3.0 to 7.3.1. - [Release notes](https://github.com/pytest-dev/pytest/releases) - [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst) - [Commits](https://github.com/pytest-dev/pytest/compare/7.3.0...7.3.1) --- updated-dependencies: - dependency-name: pytest dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 4c1a12e..8a4db0d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -676,14 +676,14 @@ tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version < \"3.11\""} [[package]] name = "pytest" -version = "7.3.0" +version = "7.3.1" description = "pytest: simple powerful testing with Python" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "pytest-7.3.0-py3-none-any.whl", hash = "sha256:933051fa1bfbd38a21e73c3960cebdad4cf59483ddba7696c48509727e17f201"}, - {file = "pytest-7.3.0.tar.gz", hash = "sha256:58ecc27ebf0ea643ebfdf7fb1249335da761a00c9f955bcd922349bcb68ee57d"}, + {file = "pytest-7.3.1-py3-none-any.whl", hash = "sha256:3799fa815351fea3a5e96ac7e503a96fa51cc9942c3753cda7651b93c1cfa362"}, + {file = "pytest-7.3.1.tar.gz", hash = "sha256:434afafd78b1d78ed0addf160ad2b77a30d35d4bdf8af234fe621919d9ed15e3"}, ] [package.dependencies] From 50a3843ecef208de833ff5d5cc8a011e24b90d13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 18 Apr 2023 14:06:16 +0200 Subject: [PATCH 2/5] build(deps-dev): bump autoflake from 2.0.2 to 2.1.0 (#28) Bumps [autoflake](https://github.com/PyCQA/autoflake) from 2.0.2 to 2.1.0. - [Release notes](https://github.com/PyCQA/autoflake/releases) - [Commits](https://github.com/PyCQA/autoflake/compare/v2.0.2...v2.1.0) --- updated-dependencies: - dependency-name: autoflake dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 8a4db0d..c68c1e1 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,14 +22,14 @@ wrapt = [ [[package]] name = "autoflake" -version = "2.0.2" +version = "2.1.0" description = "Removes unused imports and unused variables" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "autoflake-2.0.2-py3-none-any.whl", hash = "sha256:a82d8efdcbbb7129a8a23238c529fb9d9919c562e26bb7963ea6890fbfff7d02"}, - {file = "autoflake-2.0.2.tar.gz", hash = "sha256:e0164421ff13f805f08a023e249d84200bd00463d213b490906bfefa67e83830"}, + {file = "autoflake-2.1.0-py3-none-any.whl", hash = "sha256:bc15c8a2e5f259d07667ea7896643494f8422ff4cd9f8393c5d12b08fa7f2fc4"}, + {file = "autoflake-2.1.0.tar.gz", hash = "sha256:8044ba1f6c204a816df74cece0f353c6902ea08df28693ecaae82f5b1011ecd2"}, ] [package.dependencies] From 1c8cf1ea9ffe818fea96c31b4f371e80fd270678 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 25 Apr 2023 09:43:22 +0200 Subject: [PATCH 3/5] build(deps-dev): bump autoflake from 2.1.0 to 2.1.1 (#31) Bumps [autoflake](https://github.com/PyCQA/autoflake) from 2.1.0 to 2.1.1. - [Release notes](https://github.com/PyCQA/autoflake/releases) - [Commits](https://github.com/PyCQA/autoflake/compare/v2.1.0...v2.1.1) --- updated-dependencies: - dependency-name: autoflake dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- poetry.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index c68c1e1..bf27ded 100644 --- a/poetry.lock +++ b/poetry.lock @@ -22,14 +22,14 @@ wrapt = [ [[package]] name = "autoflake" -version = "2.1.0" +version = "2.1.1" description = "Removes unused imports and unused variables" category = "dev" optional = false python-versions = ">=3.7" files = [ - {file = "autoflake-2.1.0-py3-none-any.whl", hash = "sha256:bc15c8a2e5f259d07667ea7896643494f8422ff4cd9f8393c5d12b08fa7f2fc4"}, - {file = "autoflake-2.1.0.tar.gz", hash = "sha256:8044ba1f6c204a816df74cece0f353c6902ea08df28693ecaae82f5b1011ecd2"}, + {file = "autoflake-2.1.1-py3-none-any.whl", hash = "sha256:94e330a2bcf5ac01384fb2bf98bea60c6383eaa59ea62be486e376622deba985"}, + {file = "autoflake-2.1.1.tar.gz", hash = "sha256:75524b48d42d6537041d91f17573b8a98cb645642f9f05c7fcc68de10b1cade3"}, ] [package.dependencies] From 7fbe1a669033d90a31e5cf6abb0c3ec3f797ba26 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 25 Apr 2023 09:43:49 +0200 Subject: [PATCH 4/5] [pre-commit.ci] pre-commit autoupdate (#30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit updates: - [github.com/compilerla/conventional-pre-commit: v2.1.1 → v2.2.0](https://github.com/compilerla/conventional-pre-commit/compare/v2.1.1...v2.2.0) Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9711c46..d1df151 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,7 +16,7 @@ repos: - id: check-yaml - id: check-added-large-files - repo: https://github.com/compilerla/conventional-pre-commit - rev: v2.1.1 + rev: v2.2.0 hooks: - id: conventional-pre-commit stages: [commit-msg] From 7d7b951c53e304198f0ac29579f5dfe84432e70d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Thu, 27 Apr 2023 09:57:10 +0200 Subject: [PATCH 5/5] feat: add multiple handlers server (#32) * feat: add multiple handlers server * fix: missing relative_url to root * fix: update CHANGELOG.md Co-authored-by: Simon Shillaker <554768+Shillaker@users.noreply.github.com> --------- Co-authored-by: Simon Shillaker <554768+Shillaker@users.noreply.github.com> --- CHANGELOG.md | 6 ++ examples/multiple_handlers.py | 29 +++++++++ pyproject.toml | 2 +- scaleway_functions_python/__init__.py | 1 + scaleway_functions_python/local/__init__.py | 1 + scaleway_functions_python/local/serving.py | 67 ++++++++++++++++----- tests/test_local/test_serving.py | 39 ++++++++++-- 7 files changed, 126 insertions(+), 19 deletions(-) create mode 100644 examples/multiple_handlers.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a7685bd..0e0121d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,3 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Update README with link to Serverless Functions Node + +## [0.2.0] - 2023-04-23 + +### Added + +- Added a simple server to test with multiple handlers diff --git a/examples/multiple_handlers.py b/examples/multiple_handlers.py new file mode 100644 index 0000000..0f1069c --- /dev/null +++ b/examples/multiple_handlers.py @@ -0,0 +1,29 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + # Doing a conditional import avoids the need to install the library + # when deploying the function + from scaleway_functions_python.framework.v1.hints import Context, Event, Response + + +def hello(_event: "Event", _context: "Context") -> "Response": + """Say hello!""" + return {"body": "hello"} + + +def world(_event: "Event", _context: "Context") -> "Response": + """Say world!""" + return {"body": "world"} + + +if __name__ == "__main__": + from scaleway_functions_python import local + + server = local.LocalFunctionServer() + server.add_handler(hello) + server.add_handler(world) + server.serve(port=8080) + + # Functions can be queried with: + # curl localhost:8080/hello + # curl localhost:8080/world diff --git a/pyproject.toml b/pyproject.toml index f37d5a4..ea81f3a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "scaleway-functions-python" -version = "0.1.1" +version = "0.2.0" description = "Utilities for testing your Python handlers for Scaleway Serverless Functions." authors = ["Scaleway Serverless Team "] diff --git a/scaleway_functions_python/__init__.py b/scaleway_functions_python/__init__.py index 601ebd8..3535d22 100644 --- a/scaleway_functions_python/__init__.py +++ b/scaleway_functions_python/__init__.py @@ -1,3 +1,4 @@ from . import local as local from .framework import v1 as v1 +from .local.serving import LocalFunctionServer as LocalFunctionServer from .local.serving import serve_handler as serve_handler diff --git a/scaleway_functions_python/local/__init__.py b/scaleway_functions_python/local/__init__.py index 54d1307..6deb5a5 100644 --- a/scaleway_functions_python/local/__init__.py +++ b/scaleway_functions_python/local/__init__.py @@ -1 +1,2 @@ +from .serving import LocalFunctionServer as LocalFunctionServer from .serving import serve_handler as serve_handler diff --git a/scaleway_functions_python/local/serving.py b/scaleway_functions_python/local/serving.py index 6ce8534..1c6840f 100644 --- a/scaleway_functions_python/local/serving.py +++ b/scaleway_functions_python/local/serving.py @@ -1,7 +1,7 @@ import logging from base64 import b64decode from json import JSONDecodeError -from typing import TYPE_CHECKING, Any, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, List, Optional, cast from flask import Flask, json, jsonify, make_response, request from flask.views import View @@ -18,7 +18,7 @@ # TODO?: Switch to https://docs.python.org/3/library/http.html#http-methods # for Python 3.11+ -HTTP_METHODS = [ +ALL_HTTP_METHODS = [ "GET", "HEAD", "POST", @@ -144,17 +144,57 @@ def resp_record_to_flask_response( return resp -def _create_flask_app(handler: "hints.Handler") -> Flask: - app = Flask(f"serverless_local_{handler.__name__}") +class LocalFunctionServer: + """LocalFunctionServer serves Scaleway FaaS handlers on a local http server.""" - # Create the view from the handler - view = HandlerWrapper(handler).as_view(handler.__name__, handler) + def __init__(self) -> None: + self.app = Flask("serverless_local") - # By default, methods contains ["GET", "HEAD", "OPTIONS"] - app.add_url_rule("/", methods=HTTP_METHODS, view_func=view) - app.add_url_rule("/", methods=HTTP_METHODS, defaults={"path": ""}, view_func=view) + def add_handler( + self, + handler: "hints.Handler", + relative_url: Optional[str] = None, + http_methods: Optional[List[str]] = None, + ) -> "LocalFunctionServer": + """Add a handler to be served by the server. - return app + :param handler: serverless python handler + :param relative_url: path to the handler, defaults to / + handler's name + :param http_methods: HTTP methods for the handler, defaults to all methods + """ + relative_url = relative_url if relative_url else "/" + handler.__name__ + if not relative_url.startswith("/"): + relative_url = "/" + relative_url + + http_methods = http_methods if http_methods else ALL_HTTP_METHODS + http_methods = [method.upper() for method in http_methods] + + view = HandlerWrapper(handler).as_view(handler.__name__, handler) + + # By default, methods contains ["GET", "HEAD", "OPTIONS"] + self.app.add_url_rule( + f"{relative_url}/", methods=http_methods, view_func=view + ) + self.app.add_url_rule( + relative_url, + methods=http_methods, + defaults={"path": ""}, + view_func=view, + ) + + return self + + def serve( + self, *args: Any, port: int = 8080, debug: bool = True, **kwargs: Any + ) -> None: + """Serve the added FaaS handlers. + + :param port: port that the server should listen on, defaults to 8080 + :param debug: run Flask in debug mode, enables hot-reloading and stack trace. + """ + kwargs["port"] = port + kwargs["debug"] = debug + self.app.run(*args, **kwargs) def serve_handler( @@ -175,7 +215,6 @@ def serve_handler( ... return {"body": event["httpMethod"]} >>> serve_handler_locally(handle, port=8080) """ - app: Flask = _create_flask_app(handler) - kwargs["port"] = port - kwargs["debug"] = debug - app.run(*args, **kwargs) + server = LocalFunctionServer() + server.add_handler(handler=handler, relative_url="/") + server.serve(*args, port=port, debug=debug, **kwargs) diff --git a/tests/test_local/test_serving.py b/tests/test_local/test_serving.py index 65474a2..0c681ff 100644 --- a/tests/test_local/test_serving.py +++ b/tests/test_local/test_serving.py @@ -4,16 +4,17 @@ import pytest from flask.testing import FlaskClient -from scaleway_functions_python.local.serving import _create_flask_app +from scaleway_functions_python.local.serving import LocalFunctionServer from .. import handlers as h @pytest.fixture(scope="function") def client(request) -> FlaskClient: - app = _create_flask_app(request.param) - app.config.update({"TESTING": True}) - return app.test_client() + server = LocalFunctionServer() + server.add_handler(handler=request.param, relative_url="/") + server.app.config.update({"TESTING": True}) + return server.app.test_client() @pytest.mark.parametrize( @@ -89,3 +90,33 @@ def test_serve_handler_inject_infra_headers(client): assert headers["X-Forwarded-Proto"] == "http" uuid.UUID(headers["X-Request-Id"]) + + +def test_local_function_server_multiple_routes(): + # Setup a server with two handlers + server = LocalFunctionServer() + server.add_handler( + handler=h.handler_that_returns_string, + relative_url="/message", + http_methods=["GET"], # type: ignore + ) + server.add_handler( + handler=h.handler_returns_exception, + relative_url="kaboom", + http_methods=["POST", "PUT"], # type: ignore + ) # type: ignore + server.app.config.update({"TESTING": True}) + client = server.app.test_client() + + resp = client.get("/message") + assert resp.text == h.HELLO_WORLD + + resp = client.post("/message") + assert resp.status_code == 405 # Method not allowed + + resp = client.get("/kaboom") + assert resp.status_code == 405 + + with pytest.raises(Exception) as e: + client.put("/kaboom") + assert str(e) == h.EXCEPTION_MESSAGE