From 4938ce8e9d6e283eb0224d04c8d7c48fbaf2eede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Mon, 27 Feb 2023 15:33:36 +0100 Subject: [PATCH 01/18] feat: migrate to github --- examples/README.md | 1 + examples/mirror.py | 6 +- pyproject.toml | 6 +- src/framework/__init__.py | 1 + src/framework/hints/__init__.py | 1 + src/framework/hints/v1.py | 58 ++++++++ src/scaleway_functions_python/__init__.py | 3 + src/testing/__init__.py | 1 + src/testing/context.py | 13 ++ src/testing/event.py | 45 ++++++ src/testing/infra.py | 36 +++++ src/testing/serving.py | 168 ++++++++++++++++++++++ tests/handlers.py | 48 +++++++ tests/test_testing/__init__.py | 0 tests/test_testing/test_event.py | 54 +++++++ tests/test_testing/test_server.py | 88 ++++++++++++ 16 files changed, 525 insertions(+), 4 deletions(-) create mode 100644 examples/README.md create mode 100644 src/framework/hints/__init__.py create mode 100644 src/framework/hints/v1.py create mode 100644 src/scaleway_functions_python/__init__.py create mode 100644 src/testing/context.py create mode 100644 src/testing/event.py create mode 100644 src/testing/infra.py create mode 100644 src/testing/serving.py create mode 100644 tests/handlers.py create mode 100644 tests/test_testing/__init__.py create mode 100644 tests/test_testing/test_event.py create mode 100644 tests/test_testing/test_server.py diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..df635b4 --- /dev/null +++ b/examples/README.md @@ -0,0 +1 @@ +# Examples diff --git a/examples/mirror.py b/examples/mirror.py index b1615a6..6b4f371 100644 --- a/examples/mirror.py +++ b/examples/mirror.py @@ -2,7 +2,9 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from serverless_functions_python import Context, Event, Response + # Doing a conditional import avoids the need to install the library + # when deploying the function + from scaleway_functions_python.hints.v1 import Context, Event, Response def handler(event: "Event", context: "Context") -> "Response": @@ -18,6 +20,6 @@ def handler(event: "Event", context: "Context") -> "Response": if __name__ == "__main__": - from serverless_functions_python import serve_handler_locally + from scaleway_functions_python import serve_handler_locally serve_handler_locally(handler) diff --git a/pyproject.toml b/pyproject.toml index 8aad838..0340fd3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "serverless-functions-python" +name = "scaleway_functions_python" version = "0.1.0" description = "Framework to provide a good developer experience when writing Serverless Functions in Python." authors = ["Scaleway Serverless Team "] @@ -68,7 +68,9 @@ max-line-length = 89 good-names = "i,fp,e" [tool.pylint-per-file-ignores] -# Redfined outer name is for pytest fixtures +# Import aliases are prefered over unused imports or __all__ +"__init__.py" = "useless-import-alias" +# Redefined outer name is for pytest fixtures "/tests/" = "missing-class-docstring,missing-function-docstring,protected-access,redefined-outer-name" [tool.isort] diff --git a/src/framework/__init__.py b/src/framework/__init__.py index e69de29..0c4253d 100644 --- a/src/framework/__init__.py +++ b/src/framework/__init__.py @@ -0,0 +1 @@ +from . import hints as hints diff --git a/src/framework/hints/__init__.py b/src/framework/hints/__init__.py new file mode 100644 index 0000000..30b2001 --- /dev/null +++ b/src/framework/hints/__init__.py @@ -0,0 +1 @@ +from . import v1 as v1 diff --git a/src/framework/hints/v1.py b/src/framework/hints/v1.py new file mode 100644 index 0000000..925631c --- /dev/null +++ b/src/framework/hints/v1.py @@ -0,0 +1,58 @@ +import typing as t + +try: + from typing import NotRequired # type: ignore +except ImportError: + from typing_extensions import NotRequired + + +class RequestContext(t.TypedDict): + """Request context that is sent in the http event.""" + + accountId: str + resourceId: str + stage: str + requestId: str + resourcePath: str + authorizer: t.Literal[None] + httpMethod: str + apiId: str + + +class Event(t.TypedDict): + """Event dictionnary passed to the function.""" + + path: str + httpMethod: str + headers: dict[str, str] + multiValueHeaders: t.Literal[None] + queryStringParameters: dict[str, str] + multiValueQueryStringParameters: t.Literal[None] + pathParameters: t.Literal[None] + stageVariable: dict[str, str] + requestContext: RequestContext + body: str + isBase64Encoded: NotRequired[t.Literal[True]] + + +class Context(t.TypedDict): + """Context dictionnary passed to the function.""" + + memoryLimitInMb: int + functionName: str + functionVersion: str + + +class ResponseRecord(t.TypedDict, total=False): + """Response dictionnary that the handler is expected to return.""" + + body: str + headers: dict[str, str] + statusCode: int + isBase64Encoded: bool + + +# Type that the Serverless handler is expected to return +Response = t.Union[str, ResponseRecord] + +Handler = t.Callable[[Event, Context], Response] diff --git a/src/scaleway_functions_python/__init__.py b/src/scaleway_functions_python/__init__.py new file mode 100644 index 0000000..41b094e --- /dev/null +++ b/src/scaleway_functions_python/__init__.py @@ -0,0 +1,3 @@ +import testing as testing +from framework import hints as hints +from testing.serving import serve_handler_locally as serve_handler_locally diff --git a/src/testing/__init__.py b/src/testing/__init__.py index e69de29..9417d2f 100644 --- a/src/testing/__init__.py +++ b/src/testing/__init__.py @@ -0,0 +1 @@ +from .serving import serve_handler_locally diff --git a/src/testing/context.py b/src/testing/context.py new file mode 100644 index 0000000..31317fb --- /dev/null +++ b/src/testing/context.py @@ -0,0 +1,13 @@ +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from serverless_functions_python.hints import Context, Handler + + +def format_context(handler: "Handler") -> "Context": + """Formats the request context from the request.""" + return { + "memoryLimitInMb": 128, + "functionName": handler.__name__, + "functionVersion": "", + } diff --git a/src/testing/event.py b/src/testing/event.py new file mode 100644 index 0000000..45562b8 --- /dev/null +++ b/src/testing/event.py @@ -0,0 +1,45 @@ +import binascii +from base64 import b64decode +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from flask.wrappers import Request + from serverless_functions_python.hints import Event, RequestContext + + +def format_request_context(request: "Request") -> "RequestContext": + """Format the request context from the request.""" + return { + "accountId": "", + "resourceId": "", + "stage": "", + "requestId": "", + "resourcePath": "", + "authorizer": None, + "httpMethod": request.method, + "apiId": "", + } + + +def format_http_event(request: "Request") -> "Event": + """Format the event from a generic http request.""" + context = format_request_context(request) + body = request.get_data(as_text=True) + event: "Event" = { + "path": request.path, + "httpMethod": request.method, + "headers": dict(request.headers.items()), + "multiValueHeaders": None, + "queryStringParameters": request.args.to_dict(), + "multiValueQueryStringParameters": None, + "pathParameters": None, + "stageVariable": {}, + "requestContext": context, + "body": body, + } + try: + b64decode(body, validate=True).decode("utf-8") + event["isBase64Encoded"] = True + except (binascii.Error, UnicodeDecodeError): + pass + return event diff --git a/src/testing/infra.py b/src/testing/infra.py new file mode 100644 index 0000000..dce289f --- /dev/null +++ b/src/testing/infra.py @@ -0,0 +1,36 @@ +"""Utility module to inject provider-side headers.""" + +import uuid +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from flask.wrappers import Request, Response + from serverless_functions_python.hints import Event + + +def inject_ingress_headers(request: "Request", event: "Event"): + """Inject headers for incoming requests. + + ..note:: + + Because WGSI request headers are immutable, + it's simpler to inject them into the event object directly. + """ + if not request.remote_addr: + raise RuntimeWarning("remote_addr is not set in the request") + headers = { + "Forwarded": f"for={request.remote_addr};proto=http", + "X-Forwarded-For": request.remote_addr, + "X-Envoy-External-Adrdress": request.remote_addr, + "X-Forwarded-Proto": "http", + # In this context "X-Forwared-For" == "X-Envoy-External-Address" + # this property doesn't hold for actual functions + "X-Envoy-External-Address": request.remote_addr, + "X-Request-Id": str(uuid.uuid4()), + } + event["headers"] |= headers + + +def inject_egress_headers(response: "Response"): + """Inject headers for outgoing requests.""" + response.headers.add("server", "envoy") diff --git a/src/testing/serving.py b/src/testing/serving.py new file mode 100644 index 0000000..e828a7d --- /dev/null +++ b/src/testing/serving.py @@ -0,0 +1,168 @@ +import logging +from base64 import b64decode +from json import JSONDecodeError +from typing import TYPE_CHECKING, ClassVar, cast + +from flask import Flask, json, jsonify, make_response, request +from flask.views import View + +from testing import infra +from testing.context import format_context +from testing.event import format_http_event + +if TYPE_CHECKING: + from flask.wrappers import Request as FlaskRequest + from flask.wrappers import Response as FlaskResponse + from serverless_functions_python import hints + +# TODO?: Switch to https://docs.python.org/3/library/http.html#http-methods +# for Python 3.11+ +HTTP_METHODS = [ + "GET", + "HEAD", + "POST", + "PUT", + "DELETE", + "CONNECT", + "OPTIONS", + "TRACE", + "PATCH", +] +MAX_CONTENT_LENGTH = 6291456 + + +class HandlerWrapper(View): + """View that emulates the provider-side processing of requests.""" + + init_every_request: ClassVar[bool] = False + + def __init__(self, handler: "hints.Handler") -> None: + self.handler = handler + + @property + def logger(self) -> "logging.Logger": + """Utility function to get a logger.""" + return logging.getLogger(self.handler.__name__) + + def dispatch_request(self, *_args, **_kwargs): + """Handle http requests.""" + self.emulate_core_preprocess(request) + + event = format_http_event(request) + infra.inject_ingress_headers(request, event) + + context = format_context(self.handler) + + sub_response = self.emulate_subruntime(event, context) + record = self.emulate_core_postprocess(sub_response) + + resp = self.resp_record_to_flask_response(record) + infra.inject_egress_headers(resp) + + return resp + + def emulate_core_preprocess(self, req: "FlaskRequest"): + """Emulate the CoreRT guard.""" + if req.content_length and req.content_length > MAX_CONTENT_LENGTH: + self.logger.warning( + "Request is too big, should not exceed %s Mb but is %s Mb", + MAX_CONTENT_LENGTH / (1 << 20), + request.content_length / (1 << 20), # type: ignore + ) + if req.path in ["/favicon.ico", "/robots.txt"]: + self.logger.warning( + "Requests to either favicon.ico or robots.txt are dropped" + ) + + def emulate_subruntime( + self, event: "hints.Event", context: "hints.Context" + ) -> "FlaskResponse": + """Emulate the subruntime.""" + try: + function_result = self.handler(event, context) + except Exception as e: # pylint: disable=broad-exception-caught # from subRT + return make_response(str(e), 500) + if isinstance(function_result, str): + return make_response(function_result) + return jsonify(function_result) + + def emulate_core_postprocess( + self, sub_response: "FlaskResponse" + ) -> "hints.ResponseRecord": + """Emulate the CoreRT runtime response processing. + + While it seems unecessary to generate an intermediate response, + the serialization followed by a deserizalization does affect the final response. + It also makes it easier to maintain compatibility with the CoreRT. + """ + body = sub_response.get_data(as_text=True) + response: "hints.ResponseRecord" = { + "statusCode": sub_response.status_code, + "headers": dict(sub_response.headers.items()), + "body": body, + } + try: + record = json.loads(body) + if not isinstance(record, dict): + return response + + # Not using the |= operator to manually drop unexpected keys + response = cast( + "hints.ResponseRecord", + { + key: val + for key, val in record.items() + if key in response or key == "isBase64Encoded" + }, + ) + return response + except JSONDecodeError: + return response + + def resp_record_to_flask_response( + self, record: "hints.ResponseRecord" + ) -> "FlaskResponse": + """Transform the ReponseRecord into an http reponse.""" + body = record.get("body", "") + if record.get("isBase64Encoded") and body: + body = b64decode(body.encode("utf-8"), validate=True) + + resp = make_response(body, record.get("statusCode")) + + # Those headers are added for convenience, but will be + # overwritten if set in the handler + resp.headers.add("Access-Control-Allow-Origin", "*") + resp.headers.add("Access-Control-Allow-Headers", "Content-Type") + + resp.headers.update(record.get("headers") or {}) + + return resp + + +def _create_flask_app(handler: "hints.Handler") -> Flask: + app = Flask(f"python_offline_{handler.__name__}") + + # Create the view from the handler + view = HandlerWrapper(handler).as_view(handler.__name__, handler) + + # 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) + + return app + + +def serve_handler_locally(handler: "hints.Handler", *args, port: int = 9000, **kwargs): + """Serve a single FaaS handler on a local http server. + + :param handler: serverless python handler + :param port: port that the server should listen on, defaults to 9000 + + Example: + >>> def handle(event, _context): + ... return {"body": event["httpMethod"]} + >>> serve_handler_locally(handle, port=8080) + """ + app = _create_flask_app(handler) + kwargs["port"] = port + app.run(*args, **kwargs) diff --git a/tests/handlers.py b/tests/handlers.py new file mode 100644 index 0000000..fc569b4 --- /dev/null +++ b/tests/handlers.py @@ -0,0 +1,48 @@ +"""A collection of handlers used for testing.""" + +import base64 +import json + +HELLO_WORLD = "Hello World" +EXCEPTION_MESSAGE = "oops" + +# pylint: disable=missing-function-docstring + + +def mirror_handler(event, context): # noqa + return { + "headers": {"Content-Type": "application/json"}, + "body": json.dumps({"event": event, "context": context}), + } + + +def handler_that_returns_string(_event, _context): # noqa + return HELLO_WORLD + + +def handler_with_404_status(_event, _context): # noqa + return {"statusCode": 404} + + +def handler_with_content_type(_event, _context): # noqa + return { + "body": HELLO_WORLD, + "headers": { + "Content-Type": ["text/plain"], + }, + } + + +def handler_returns_is_base_64_encoded(event, _context): # noqa + return event.get("isBase64Encoded") + + +def handler_returns_base64_encoded_body(_event, _context): # noqa + return { + "body": base64.b64encode(HELLO_WORLD.encode("utf-8")).decode("utf-8"), + "isBase64Encoded": True, + } + + +def handler_returns_exception(_event, _context): # noqa + raise RuntimeError(EXCEPTION_MESSAGE) diff --git a/tests/test_testing/__init__.py b/tests/test_testing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_testing/test_event.py b/tests/test_testing/test_event.py new file mode 100644 index 0000000..a9d58e1 --- /dev/null +++ b/tests/test_testing/test_event.py @@ -0,0 +1,54 @@ +import pytest +from flask import Flask, request + +from testing.event import format_http_event + + +@pytest.fixture() +def app(): + app = Flask("test") + app.config.update( + { + "TESTING": True, + } + ) + yield app + + +def test_format_http_event(app): + expected_request_context = { + "accountId": "", + "resourceId": "", + "stage": "", + "requestId": "", + "resourcePath": "", + "authorizer": None, + "httpMethod": "GET", + "apiId": "", + } + expected_query_string_parameters = {"param_1": "value_1", "param_2": "value_2"} + + with app.test_request_context( + query_string="param_1=value_1¶m_2=value_2", + path="/path", + headers={"Content-Type": "text/plain"}, + method="GET", + ): + app.preprocess_request() + event = format_http_event(request) + + assert event["path"] == "/path" + assert event["headers"]["Content-Type"] == "text/plain" + assert event["multiValueHeaders"] is None + + assert event["queryStringParameters"] == expected_query_string_parameters + + assert event["multiValueQueryStringParameters"] is None + assert event["pathParameters"] is None + assert not event["stageVariable"] + + assert event["isBase64Encoded"] is True + + assert event["requestContext"] == expected_request_context + + assert event["body"] == "" diff --git a/tests/test_testing/test_server.py b/tests/test_testing/test_server.py new file mode 100644 index 0000000..f275030 --- /dev/null +++ b/tests/test_testing/test_server.py @@ -0,0 +1,88 @@ +import base64 +import uuid + +import pytest +from flask.testing import FlaskClient + +from testing.serving import _create_flask_app + +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() + + +@pytest.mark.parametrize( + "client, expected", + [ + (h.handler_that_returns_string, {"statusCode": 200, "body": h.HELLO_WORLD}), + (h.handler_with_404_status, {"statusCode": 404, "body": ""}), + ( + h.handler_with_content_type, + { + "statusCode": 200, + "body": h.HELLO_WORLD, + "headers": {"Content-Type": "text/plain"}, + }, + ), + ], + indirect=["client"], +) +def test_serve_handler(client, expected): + resp = client.get("/") + + assert resp.status_code == expected.get("statusCode") + assert resp.text == expected.get("body") + + for hkey, hval in expected.get("headers", {}).items(): + assert resp.headers.get(hkey) == hval + + +@pytest.mark.parametrize( + "client", [h.handler_returns_is_base_64_encoded], indirect=True +) +def test_serve_handler_b64_parameter_correct(client): + data = base64.b64encode(h.HELLO_WORLD.encode("utf-8")) + resp = client.post("/", data=data) + assert resp.text == "true\n" + + resp = client.post("/", data=h.HELLO_WORLD) + # Gets should return None which gets json-encoded into null + assert resp.text == "null\n" + + +@pytest.mark.parametrize( + "client", [h.handler_returns_base64_encoded_body], indirect=True +) +def test_serve_handler_with_b64_encoded_body(client): + resp = client.get("/") + assert resp.text == h.HELLO_WORLD + + +@pytest.mark.parametrize("client", [h.handler_returns_exception], indirect=True) +def test_serve_handler_with_exception(client): + resp = client.get("/") + assert resp.text == h.EXCEPTION_MESSAGE + + +@pytest.mark.parametrize("client", [h.mirror_handler], indirect=True) +def test_serve_handler_inject_infra_headers(client): + resp = client.get("/") + + # Check the response headers + assert resp.headers["server"] == "envoy" + + body = resp.get_json() + headers = body["event"]["headers"] + + # Check the headers injected in the event object + assert headers["Forwarded"] == "for=127.0.0.1;proto=http" + assert headers["X-Forwarded-For"] == "127.0.0.1" + assert headers["X-Envoy-External-Adrdress"] == "127.0.0.1" + assert headers["X-Forwarded-Proto"] == "http" + + uuid.UUID(headers["X-Request-Id"]) From 0bf7719dc0ac8986309c8bdb0e850cb946daeadf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Mon, 27 Feb 2023 15:38:05 +0100 Subject: [PATCH 02/18] chore: stop using |= --- src/testing/infra.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/testing/infra.py b/src/testing/infra.py index dce289f..d4c8707 100644 --- a/src/testing/infra.py +++ b/src/testing/infra.py @@ -28,7 +28,8 @@ def inject_ingress_headers(request: "Request", event: "Event"): "X-Envoy-External-Address": request.remote_addr, "X-Request-Id": str(uuid.uuid4()), } - event["headers"] |= headers + # Not using |= to keep compatibility with python 3.8 + event["headers"].update(**headers) def inject_egress_headers(response: "Response"): From 5acb4de69357da6dfc4e55faedfb99aaf57d7731 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Mon, 27 Feb 2023 15:54:42 +0100 Subject: [PATCH 03/18] refactor: change versioning order --- examples/mirror.py | 2 +- src/framework/__init__.py | 2 +- src/framework/hints/__init__.py | 1 - src/framework/v1/__init__.py | 1 + src/framework/{hints/v1.py => v1/hints.py} | 0 5 files changed, 3 insertions(+), 3 deletions(-) delete mode 100644 src/framework/hints/__init__.py create mode 100644 src/framework/v1/__init__.py rename src/framework/{hints/v1.py => v1/hints.py} (100%) diff --git a/examples/mirror.py b/examples/mirror.py index 6b4f371..757041b 100644 --- a/examples/mirror.py +++ b/examples/mirror.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: # Doing a conditional import avoids the need to install the library # when deploying the function - from scaleway_functions_python.hints.v1 import Context, Event, Response + from scaleway_functions_python.v1.hints import Context, Event, Response def handler(event: "Event", context: "Context") -> "Response": diff --git a/src/framework/__init__.py b/src/framework/__init__.py index 0c4253d..30b2001 100644 --- a/src/framework/__init__.py +++ b/src/framework/__init__.py @@ -1 +1 @@ -from . import hints as hints +from . import v1 as v1 diff --git a/src/framework/hints/__init__.py b/src/framework/hints/__init__.py deleted file mode 100644 index 30b2001..0000000 --- a/src/framework/hints/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import v1 as v1 diff --git a/src/framework/v1/__init__.py b/src/framework/v1/__init__.py new file mode 100644 index 0000000..0c4253d --- /dev/null +++ b/src/framework/v1/__init__.py @@ -0,0 +1 @@ +from . import hints as hints diff --git a/src/framework/hints/v1.py b/src/framework/v1/hints.py similarity index 100% rename from src/framework/hints/v1.py rename to src/framework/v1/hints.py From 86a17f7a13594c95c5fd27bad890a30f2fb9eaf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Mon, 27 Feb 2023 16:14:25 +0100 Subject: [PATCH 04/18] refactor: raise the exception instead of catching it --- src/scaleway_functions_python/__init__.py | 2 +- src/testing/serving.py | 16 ++++++++++++---- .../{test_server.py => test_serving.py} | 6 ++++-- 3 files changed, 17 insertions(+), 7 deletions(-) rename tests/test_testing/{test_server.py => test_serving.py} (94%) diff --git a/src/scaleway_functions_python/__init__.py b/src/scaleway_functions_python/__init__.py index 41b094e..eb4b08a 100644 --- a/src/scaleway_functions_python/__init__.py +++ b/src/scaleway_functions_python/__init__.py @@ -1,3 +1,3 @@ import testing as testing -from framework import hints as hints +from framework import v1 as v1 from testing.serving import serve_handler_locally as serve_handler_locally diff --git a/src/testing/serving.py b/src/testing/serving.py index e828a7d..0e71108 100644 --- a/src/testing/serving.py +++ b/src/testing/serving.py @@ -81,7 +81,11 @@ def emulate_subruntime( try: function_result = self.handler(event, context) except Exception as e: # pylint: disable=broad-exception-caught # from subRT - return make_response(str(e), 500) + self.logger.warning( + "Exception caught in handler %s, this will return a 500 when deployed", + self.handler.__name__, + ) + raise e if isinstance(function_result, str): return make_response(function_result) return jsonify(function_result) @@ -152,17 +156,21 @@ def _create_flask_app(handler: "hints.Handler") -> Flask: return app -def serve_handler_locally(handler: "hints.Handler", *args, port: int = 9000, **kwargs): +def serve_handler_locally( + handler: "hints.Handler", *args, port: int = 8080, debug: bool = True, **kwargs +): """Serve a single FaaS handler on a local http server. :param handler: serverless python handler - :param port: port that the server should listen on, defaults to 9000 + :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. Example: >>> def handle(event, _context): ... return {"body": event["httpMethod"]} >>> serve_handler_locally(handle, port=8080) """ - app = _create_flask_app(handler) + app: Flask = _create_flask_app(handler) kwargs["port"] = port + kwargs["debug"] = debug app.run(*args, **kwargs) diff --git a/tests/test_testing/test_server.py b/tests/test_testing/test_serving.py similarity index 94% rename from tests/test_testing/test_server.py rename to tests/test_testing/test_serving.py index f275030..156ebb7 100644 --- a/tests/test_testing/test_server.py +++ b/tests/test_testing/test_serving.py @@ -65,8 +65,10 @@ def test_serve_handler_with_b64_encoded_body(client): @pytest.mark.parametrize("client", [h.handler_returns_exception], indirect=True) def test_serve_handler_with_exception(client): - resp = client.get("/") - assert resp.text == h.EXCEPTION_MESSAGE + # Not the production behavior + with pytest.raises(Exception) as e: + client.get("/") + assert str(e) == h.EXCEPTION_MESSAGE @pytest.mark.parametrize("client", [h.mirror_handler], indirect=True) From 41314e3dddfa153fe4f060889f097e9b6e0c3491 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 11:10:02 +0100 Subject: [PATCH 05/18] fix: bad imports with src layout --- pyproject.toml | 5 ----- scaleway_functions_python/__init__.py | 3 +++ .../framework/__init__.py | 0 .../framework/v1/__init__.py | 0 .../framework/v1/hints.py | 0 .../testing/__init__.py | 0 .../testing/context.py | 2 +- {src => scaleway_functions_python}/testing/event.py | 3 ++- {src => scaleway_functions_python}/testing/infra.py | 3 ++- .../testing/serving.py | 13 +++++++------ src/scaleway_functions_python/__init__.py | 3 --- tests/test_testing/test_event.py | 2 +- tests/test_testing/test_serving.py | 2 +- 13 files changed, 17 insertions(+), 19 deletions(-) create mode 100644 scaleway_functions_python/__init__.py rename {src => scaleway_functions_python}/framework/__init__.py (100%) rename {src => scaleway_functions_python}/framework/v1/__init__.py (100%) rename {src => scaleway_functions_python}/framework/v1/hints.py (100%) rename {src => scaleway_functions_python}/testing/__init__.py (100%) rename {src => scaleway_functions_python}/testing/context.py (81%) rename {src => scaleway_functions_python}/testing/event.py (94%) rename {src => scaleway_functions_python}/testing/infra.py (95%) rename {src => scaleway_functions_python}/testing/serving.py (96%) delete mode 100644 src/scaleway_functions_python/__init__.py diff --git a/pyproject.toml b/pyproject.toml index 0340fd3..d96765e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,10 +27,6 @@ classifiers = [ "Programming Language :: Python :: 3.11", ] -packages = [ - { include = "framework", from = "src" }, - { include = "testing", from = "src" }, -] include = ["CHANGELOG.md"] [tool.poetry.dependencies] @@ -56,7 +52,6 @@ requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" [tool.pytest.ini_options] -pythonpath = ["src"] testpaths = ["tests"] [tool.pylint] diff --git a/scaleway_functions_python/__init__.py b/scaleway_functions_python/__init__.py new file mode 100644 index 0000000..e3a2159 --- /dev/null +++ b/scaleway_functions_python/__init__.py @@ -0,0 +1,3 @@ +from . import testing as testing +from .framework import v1 as v1 +from .testing.serving import serve_handler_locally as serve_handler_locally diff --git a/src/framework/__init__.py b/scaleway_functions_python/framework/__init__.py similarity index 100% rename from src/framework/__init__.py rename to scaleway_functions_python/framework/__init__.py diff --git a/src/framework/v1/__init__.py b/scaleway_functions_python/framework/v1/__init__.py similarity index 100% rename from src/framework/v1/__init__.py rename to scaleway_functions_python/framework/v1/__init__.py diff --git a/src/framework/v1/hints.py b/scaleway_functions_python/framework/v1/hints.py similarity index 100% rename from src/framework/v1/hints.py rename to scaleway_functions_python/framework/v1/hints.py diff --git a/src/testing/__init__.py b/scaleway_functions_python/testing/__init__.py similarity index 100% rename from src/testing/__init__.py rename to scaleway_functions_python/testing/__init__.py diff --git a/src/testing/context.py b/scaleway_functions_python/testing/context.py similarity index 81% rename from src/testing/context.py rename to scaleway_functions_python/testing/context.py index 31317fb..8df9046 100644 --- a/src/testing/context.py +++ b/scaleway_functions_python/testing/context.py @@ -1,7 +1,7 @@ from typing import TYPE_CHECKING if TYPE_CHECKING: - from serverless_functions_python.hints import Context, Handler + from ..framework.v1.hints import Context, Handler def format_context(handler: "Handler") -> "Context": diff --git a/src/testing/event.py b/scaleway_functions_python/testing/event.py similarity index 94% rename from src/testing/event.py rename to scaleway_functions_python/testing/event.py index 45562b8..2f7ee6c 100644 --- a/src/testing/event.py +++ b/scaleway_functions_python/testing/event.py @@ -4,7 +4,8 @@ if TYPE_CHECKING: from flask.wrappers import Request - from serverless_functions_python.hints import Event, RequestContext + + from ..framework.v1.hints import Event, RequestContext def format_request_context(request: "Request") -> "RequestContext": diff --git a/src/testing/infra.py b/scaleway_functions_python/testing/infra.py similarity index 95% rename from src/testing/infra.py rename to scaleway_functions_python/testing/infra.py index d4c8707..9e89fe0 100644 --- a/src/testing/infra.py +++ b/scaleway_functions_python/testing/infra.py @@ -5,7 +5,8 @@ if TYPE_CHECKING: from flask.wrappers import Request, Response - from serverless_functions_python.hints import Event + + from ..framework.v1.hints import Event def inject_ingress_headers(request: "Request", event: "Event"): diff --git a/src/testing/serving.py b/scaleway_functions_python/testing/serving.py similarity index 96% rename from src/testing/serving.py rename to scaleway_functions_python/testing/serving.py index 0e71108..60f01cd 100644 --- a/src/testing/serving.py +++ b/scaleway_functions_python/testing/serving.py @@ -6,14 +6,15 @@ from flask import Flask, json, jsonify, make_response, request from flask.views import View -from testing import infra -from testing.context import format_context -from testing.event import format_http_event +from ..testing import infra +from ..testing.context import format_context +from ..testing.event import format_http_event if TYPE_CHECKING: from flask.wrappers import Request as FlaskRequest from flask.wrappers import Response as FlaskResponse - from serverless_functions_python import hints + + from ..framework.v1 import hints # TODO?: Switch to https://docs.python.org/3/library/http.html#http-methods # for Python 3.11+ @@ -129,7 +130,7 @@ def resp_record_to_flask_response( """Transform the ReponseRecord into an http reponse.""" body = record.get("body", "") if record.get("isBase64Encoded") and body: - body = b64decode(body.encode("utf-8"), validate=True) + body = b64decode(body.encode("utf-8"), validate=True).decode("utf-8") resp = make_response(body, record.get("statusCode")) @@ -144,7 +145,7 @@ def resp_record_to_flask_response( def _create_flask_app(handler: "hints.Handler") -> Flask: - app = Flask(f"python_offline_{handler.__name__}") + app = Flask(f"serverless_local_{handler.__name__}") # Create the view from the handler view = HandlerWrapper(handler).as_view(handler.__name__, handler) diff --git a/src/scaleway_functions_python/__init__.py b/src/scaleway_functions_python/__init__.py deleted file mode 100644 index eb4b08a..0000000 --- a/src/scaleway_functions_python/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -import testing as testing -from framework import v1 as v1 -from testing.serving import serve_handler_locally as serve_handler_locally diff --git a/tests/test_testing/test_event.py b/tests/test_testing/test_event.py index a9d58e1..1713d18 100644 --- a/tests/test_testing/test_event.py +++ b/tests/test_testing/test_event.py @@ -1,7 +1,7 @@ import pytest from flask import Flask, request -from testing.event import format_http_event +from scaleway_functions_python.testing.event import format_http_event @pytest.fixture() diff --git a/tests/test_testing/test_serving.py b/tests/test_testing/test_serving.py index 156ebb7..0b26519 100644 --- a/tests/test_testing/test_serving.py +++ b/tests/test_testing/test_serving.py @@ -4,7 +4,7 @@ import pytest from flask.testing import FlaskClient -from testing.serving import _create_flask_app +from scaleway_functions_python.testing.serving import _create_flask_app from .. import handlers as h From 45ec41189ceaceaf9ce5ff9833cc78f3e7b5607c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 11:22:18 +0100 Subject: [PATCH 06/18] fix(framework): use older hints api to keep python3.8 compat --- scaleway_functions_python/framework/v1/hints.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scaleway_functions_python/framework/v1/hints.py b/scaleway_functions_python/framework/v1/hints.py index 925631c..2cb9913 100644 --- a/scaleway_functions_python/framework/v1/hints.py +++ b/scaleway_functions_python/framework/v1/hints.py @@ -24,12 +24,12 @@ class Event(t.TypedDict): path: str httpMethod: str - headers: dict[str, str] + headers: t.Dict[str, str] multiValueHeaders: t.Literal[None] - queryStringParameters: dict[str, str] + queryStringParameters: t.Dict[str, str] multiValueQueryStringParameters: t.Literal[None] pathParameters: t.Literal[None] - stageVariable: dict[str, str] + stageVariable: t.Dict[str, str] requestContext: RequestContext body: str isBase64Encoded: NotRequired[t.Literal[True]] @@ -47,7 +47,7 @@ class ResponseRecord(t.TypedDict, total=False): """Response dictionnary that the handler is expected to return.""" body: str - headers: dict[str, str] + headers: t.Dict[str, str] statusCode: int isBase64Encoded: bool From 2a36352f23f71d9940d3c017237dd8d23a4d4b35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 11:29:57 +0100 Subject: [PATCH 07/18] chore: disable locally mypy because of issue with not-required --- pyproject.toml | 6 ++++++ scaleway_functions_python/testing/event.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d96765e..94d1fbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,6 +71,12 @@ good-names = "i,fp,e" [tool.isort] profile = "black" +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +check_untyped_defs = true + [tool.pydocstyle] # Compatible with Sphinx convention = "google" diff --git a/scaleway_functions_python/testing/event.py b/scaleway_functions_python/testing/event.py index 2f7ee6c..6a9efb4 100644 --- a/scaleway_functions_python/testing/event.py +++ b/scaleway_functions_python/testing/event.py @@ -37,7 +37,7 @@ def format_http_event(request: "Request") -> "Event": "stageVariable": {}, "requestContext": context, "body": body, - } + } # type: ignore # NotRequired works with Pylance here but not mypy 1.0 (bug?) try: b64decode(body, validate=True).decode("utf-8") event["isBase64Encoded"] = True From 4aa180a9cf37511d7c864f94b2122cbb43fe7137 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 11:53:22 +0100 Subject: [PATCH 08/18] fix(examples): change hints location --- examples/mirror.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/mirror.py b/examples/mirror.py index 757041b..8e4dcf0 100644 --- a/examples/mirror.py +++ b/examples/mirror.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: # Doing a conditional import avoids the need to install the library # when deploying the function - from scaleway_functions_python.v1.hints import Context, Event, Response + from scaleway_functions_python.framework.v1.hints import Context, Event, Response def handler(event: "Event", context: "Context") -> "Response": From 38cccb6c72345dfc7aaa4175e70e12e3862435c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 12:16:53 +0100 Subject: [PATCH 09/18] chore: run mypy in strict mode --- pyproject.toml | 5 ++--- scaleway_functions_python/testing/infra.py | 4 ++-- scaleway_functions_python/testing/serving.py | 18 +++++++++++------- 3 files changed, 15 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 94d1fbb..2d00f1f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,9 +73,8 @@ profile = "black" [tool.mypy] python_version = "3.8" -warn_return_any = true -warn_unused_configs = true -check_untyped_defs = true +strict = true +exclude = ['^tests\.*'] [tool.pydocstyle] # Compatible with Sphinx diff --git a/scaleway_functions_python/testing/infra.py b/scaleway_functions_python/testing/infra.py index 9e89fe0..a89e55e 100644 --- a/scaleway_functions_python/testing/infra.py +++ b/scaleway_functions_python/testing/infra.py @@ -9,7 +9,7 @@ from ..framework.v1.hints import Event -def inject_ingress_headers(request: "Request", event: "Event"): +def inject_ingress_headers(request: "Request", event: "Event") -> None: """Inject headers for incoming requests. ..note:: @@ -33,6 +33,6 @@ def inject_ingress_headers(request: "Request", event: "Event"): event["headers"].update(**headers) -def inject_egress_headers(response: "Response"): +def inject_egress_headers(response: "Response") -> None: """Inject headers for outgoing requests.""" response.headers.add("server", "envoy") diff --git a/scaleway_functions_python/testing/serving.py b/scaleway_functions_python/testing/serving.py index 60f01cd..7324821 100644 --- a/scaleway_functions_python/testing/serving.py +++ b/scaleway_functions_python/testing/serving.py @@ -1,7 +1,7 @@ import logging from base64 import b64decode from json import JSONDecodeError -from typing import TYPE_CHECKING, ClassVar, cast +from typing import TYPE_CHECKING, Any, ClassVar, cast from flask import Flask, json, jsonify, make_response, request from flask.views import View @@ -32,7 +32,7 @@ MAX_CONTENT_LENGTH = 6291456 -class HandlerWrapper(View): +class HandlerWrapper(View): # type: ignore # Subclass of untyped class """View that emulates the provider-side processing of requests.""" init_every_request: ClassVar[bool] = False @@ -45,7 +45,7 @@ def logger(self) -> "logging.Logger": """Utility function to get a logger.""" return logging.getLogger(self.handler.__name__) - def dispatch_request(self, *_args, **_kwargs): + def dispatch_request(self, *_args: Any, **_kwargs: Any) -> "FlaskResponse": """Handle http requests.""" self.emulate_core_preprocess(request) @@ -62,13 +62,13 @@ def dispatch_request(self, *_args, **_kwargs): return resp - def emulate_core_preprocess(self, req: "FlaskRequest"): + def emulate_core_preprocess(self, req: "FlaskRequest") -> None: """Emulate the CoreRT guard.""" if req.content_length and req.content_length > MAX_CONTENT_LENGTH: self.logger.warning( "Request is too big, should not exceed %s Mb but is %s Mb", MAX_CONTENT_LENGTH / (1 << 20), - request.content_length / (1 << 20), # type: ignore + request.content_length / (1 << 20), ) if req.path in ["/favicon.ico", "/robots.txt"]: self.logger.warning( @@ -158,8 +158,12 @@ def _create_flask_app(handler: "hints.Handler") -> Flask: def serve_handler_locally( - handler: "hints.Handler", *args, port: int = 8080, debug: bool = True, **kwargs -): + handler: "hints.Handler", + *args: Any, + port: int = 8080, + debug: bool = True, + **kwargs: Any, +) -> None: """Serve a single FaaS handler on a local http server. :param handler: serverless python handler From 104312c4eb8389df1e5fa7bcd07e7e02b65e4c1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 12:22:47 +0100 Subject: [PATCH 10/18] fix: small error in code found by pylance --- scaleway_functions_python/testing/serving.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scaleway_functions_python/testing/serving.py b/scaleway_functions_python/testing/serving.py index 7324821..1b58c61 100644 --- a/scaleway_functions_python/testing/serving.py +++ b/scaleway_functions_python/testing/serving.py @@ -68,7 +68,7 @@ def emulate_core_preprocess(self, req: "FlaskRequest") -> None: self.logger.warning( "Request is too big, should not exceed %s Mb but is %s Mb", MAX_CONTENT_LENGTH / (1 << 20), - request.content_length / (1 << 20), + req.content_length / (1 << 20), ) if req.path in ["/favicon.ico", "/robots.txt"]: self.logger.warning( From 7935326020f43fdc17e0f62eb91f36797942a65a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 16:05:49 +0100 Subject: [PATCH 11/18] chore: rename project with dashes --- .pre-commit-config.yaml | 10 ++++++---- examples/README.md | 1 - pyproject.toml | 6 +++--- tests/test_fake.py | 2 -- 4 files changed, 9 insertions(+), 10 deletions(-) delete mode 100644 examples/README.md delete mode 100644 tests/test_fake.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 01878f5..1df7c0e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,13 +51,15 @@ repos: additional_dependencies: - tomli # for reading config from pyproject.toml - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.0.0 + rev: v1.0.1 hooks: - id: mypy + exclude: "^tests/" # See: https://github.com/pre-commit/mirrors-mypy/issues/1 + args: [--ignore-missing-imports] # Needed because pre-commit run mypy in venv additional_dependencies: - typing_extensions - - repo: https://github.com/Lucas-C/pre-commit-hooks-bandit - rev: v1.0.6 + - repo: https://github.com/PyCQA/bandit + rev: 1.7.4 hooks: - - id: python-bandit-vulnerability-check + - id: bandit args: [--skip, B101, --recursive, clumper] diff --git a/examples/README.md b/examples/README.md deleted file mode 100644 index df635b4..0000000 --- a/examples/README.md +++ /dev/null @@ -1 +0,0 @@ -# Examples diff --git a/pyproject.toml b/pyproject.toml index 2d00f1f..6310295 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [tool.poetry] -name = "scaleway_functions_python" +name = "scaleway-functions-python" version = "0.1.0" description = "Framework to provide a good developer experience when writing Serverless Functions in Python." authors = ["Scaleway Serverless Team "] @@ -60,7 +60,7 @@ disable = "missing-module-docstring" # Commented Black formatted code. max-line-length = 89 # Short and common names. e is commonly used for exceptions. -good-names = "i,fp,e" +good-names = "i,e" [tool.pylint-per-file-ignores] # Import aliases are prefered over unused imports or __all__ @@ -74,7 +74,7 @@ profile = "black" [tool.mypy] python_version = "3.8" strict = true -exclude = ['^tests\.*'] +exclude = "tests" [tool.pydocstyle] # Compatible with Sphinx diff --git a/tests/test_fake.py b/tests/test_fake.py deleted file mode 100644 index fa77e7b..0000000 --- a/tests/test_fake.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_answer(): - assert True From 71a83d464e9c0ec0ac29596ee854f8681b9aed32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 16:09:37 +0100 Subject: [PATCH 12/18] chore: refrasing and spacing mistakes --- .pre-commit-config.yaml | 2 +- README.md | 2 +- examples/mirror.py | 2 +- pyproject.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1df7c0e..00320c5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -55,7 +55,7 @@ repos: hooks: - id: mypy exclude: "^tests/" # See: https://github.com/pre-commit/mirrors-mypy/issues/1 - args: [--ignore-missing-imports] # Needed because pre-commit run mypy in venv + args: [--ignore-missing-imports] # Needed because pre-commit runs mypy in a venv additional_dependencies: - typing_extensions - repo: https://github.com/PyCQA/bandit diff --git a/README.md b/README.md index 4a6c93a..34b49f7 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Serverless Functions Python 💜 -This repo contains utilities for testing your Python functions for Scaleway Serverless Functions. +This repo contains utilities for testing your Python handlers for Scaleway Serverless Functions. ## ⚙️ Quick Start diff --git a/examples/mirror.py b/examples/mirror.py index 8e4dcf0..9c0dd65 100644 --- a/examples/mirror.py +++ b/examples/mirror.py @@ -3,7 +3,7 @@ if TYPE_CHECKING: # Doing a conditional import avoids the need to install the library - # when deploying the function + # when deploying the function from scaleway_functions_python.framework.v1.hints import Context, Event, Response diff --git a/pyproject.toml b/pyproject.toml index 6310295..ebd8140 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,7 +1,7 @@ [tool.poetry] name = "scaleway-functions-python" version = "0.1.0" -description = "Framework to provide a good developer experience when writing Serverless Functions in Python." +description = "Utilities for testing your Python handlers for Scaleway Serverless Functions." authors = ["Scaleway Serverless Team "] readme = "README.md" From b5a1bee5d344961b95c5dec02dd8386bff887352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 16:14:31 +0100 Subject: [PATCH 13/18] chore(test): format dict in test --- tests/test_testing/test_event.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/tests/test_testing/test_event.py b/tests/test_testing/test_event.py index 1713d18..c5c5a1b 100644 --- a/tests/test_testing/test_event.py +++ b/tests/test_testing/test_event.py @@ -7,11 +7,7 @@ @pytest.fixture() def app(): app = Flask("test") - app.config.update( - { - "TESTING": True, - } - ) + app.config.update({"TESTING": True}) yield app From c58df9c9082bbb262041ed79960cbf3d50063264 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Tue, 28 Feb 2023 16:59:44 +0100 Subject: [PATCH 14/18] chore(ci): remove reporting --- .github/workflows/pytest.yml | 14 ++------------ .github/workflows/report.yml | 26 -------------------------- 2 files changed, 2 insertions(+), 38 deletions(-) delete mode 100644 .github/workflows/report.yml diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index a86aa4f..3277aad 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -14,7 +14,7 @@ jobs: test: strategy: matrix: - python-version: ['3.8', '3.9', '3.10', '3.11'] + python-version: ["3.8", "3.9", "3.10", "3.11"] runs-on: ubuntu-22.04 steps: @@ -39,14 +39,4 @@ jobs: - name: Test with pytest working-directory: tests - run: poetry run pytest --junitxml=junit/test-results-${{ matrix.python-version - }}.xml - - - name: Upload pytest report - uses: actions/upload-artifact@v3 - with: - name: pytest-results-${{ matrix.python-version }} - path: tests/junit/test-results-${{ matrix.python-version }}.xml - retention-days: 3 - # Use always() to always run this step to publish test results when there are test failures - if: ${{ always() }} + run: poetry run pytest diff --git a/.github/workflows/report.yml b/.github/workflows/report.yml deleted file mode 100644 index 7224511..0000000 --- a/.github/workflows/report.yml +++ /dev/null @@ -1,26 +0,0 @@ ---- -name: report - -on: - workflow_run: - workflows: [pytest] - types: [completed] - -permissions: - checks: write - -jobs: - checks: - runs-on: ubuntu-22.04 - steps: - - name: Download Test Report - uses: dawidd6/action-download-artifact@v2 - with: - workflow: ${{ github.event.workflow.id }} - run_id: ${{ github.event.workflow_run.id }} - - - name: Publish Test Report - uses: mikepenz/action-junit-report@v3 - with: - commit: ${{github.event.workflow_run.head_sha}} - report_paths: tests/junit/test-results-*.xml From 6aa9a30fe0cbd955df6c5c78fe4a6c63396445d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Wed, 1 Mar 2023 10:09:06 +0100 Subject: [PATCH 15/18] docs: update emoji in readme reach-us for consistency accross repo --- .github/CONTRIBUTING.md | 2 +- README.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 70c0c51..a9234ce 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -79,4 +79,4 @@ Keep in mind only the **pull request title** will be used as the commit message See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). -Thank you for reading through all of this, if you have any questions feel free to [reach us](../README.md#💜-reach-us)! +Thank you for reading through all of this, if you have any questions feel free to [reach us](../README.md#📭-reach-us)! diff --git a/README.md b/README.md index 34b49f7..d8cc55f 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ We welcome all contributions to our open-source projects, please see our [contri Do not hesitate to raise issues and pull requests we will have a look at them. -## 💜 Reach Us +## 📭 Reach Us We love feedback. Feel free to: From a19b274d3ac9d052df1d90feca09d8825066cd10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Thu, 2 Mar 2023 10:38:59 +0100 Subject: [PATCH 16/18] refactor: rename folders --- examples/mirror.py | 4 ++-- scaleway_functions_python/__init__.py | 4 ++-- scaleway_functions_python/local/__init__.py | 1 + scaleway_functions_python/{testing => local}/context.py | 0 scaleway_functions_python/{testing => local}/event.py | 0 scaleway_functions_python/{testing => local}/infra.py | 0 scaleway_functions_python/{testing => local}/serving.py | 8 ++++---- scaleway_functions_python/testing/__init__.py | 1 - tests/{test_testing => test_local}/__init__.py | 0 tests/{test_testing => test_local}/test_event.py | 2 +- tests/{test_testing => test_local}/test_serving.py | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) create mode 100644 scaleway_functions_python/local/__init__.py rename scaleway_functions_python/{testing => local}/context.py (100%) rename scaleway_functions_python/{testing => local}/event.py (100%) rename scaleway_functions_python/{testing => local}/infra.py (100%) rename scaleway_functions_python/{testing => local}/serving.py (97%) delete mode 100644 scaleway_functions_python/testing/__init__.py rename tests/{test_testing => test_local}/__init__.py (100%) rename tests/{test_testing => test_local}/test_event.py (95%) rename tests/{test_testing => test_local}/test_serving.py (97%) diff --git a/examples/mirror.py b/examples/mirror.py index 9c0dd65..efbdcb5 100644 --- a/examples/mirror.py +++ b/examples/mirror.py @@ -20,6 +20,6 @@ def handler(event: "Event", context: "Context") -> "Response": if __name__ == "__main__": - from scaleway_functions_python import serve_handler_locally + from scaleway_functions_python import local - serve_handler_locally(handler) + local.serve_handler(handler) diff --git a/scaleway_functions_python/__init__.py b/scaleway_functions_python/__init__.py index e3a2159..601ebd8 100644 --- a/scaleway_functions_python/__init__.py +++ b/scaleway_functions_python/__init__.py @@ -1,3 +1,3 @@ -from . import testing as testing +from . import local as local from .framework import v1 as v1 -from .testing.serving import serve_handler_locally as serve_handler_locally +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 new file mode 100644 index 0000000..54d1307 --- /dev/null +++ b/scaleway_functions_python/local/__init__.py @@ -0,0 +1 @@ +from .serving import serve_handler as serve_handler diff --git a/scaleway_functions_python/testing/context.py b/scaleway_functions_python/local/context.py similarity index 100% rename from scaleway_functions_python/testing/context.py rename to scaleway_functions_python/local/context.py diff --git a/scaleway_functions_python/testing/event.py b/scaleway_functions_python/local/event.py similarity index 100% rename from scaleway_functions_python/testing/event.py rename to scaleway_functions_python/local/event.py diff --git a/scaleway_functions_python/testing/infra.py b/scaleway_functions_python/local/infra.py similarity index 100% rename from scaleway_functions_python/testing/infra.py rename to scaleway_functions_python/local/infra.py diff --git a/scaleway_functions_python/testing/serving.py b/scaleway_functions_python/local/serving.py similarity index 97% rename from scaleway_functions_python/testing/serving.py rename to scaleway_functions_python/local/serving.py index 1b58c61..6ce8534 100644 --- a/scaleway_functions_python/testing/serving.py +++ b/scaleway_functions_python/local/serving.py @@ -6,9 +6,9 @@ from flask import Flask, json, jsonify, make_response, request from flask.views import View -from ..testing import infra -from ..testing.context import format_context -from ..testing.event import format_http_event +from ..local import infra +from ..local.context import format_context +from ..local.event import format_http_event if TYPE_CHECKING: from flask.wrappers import Request as FlaskRequest @@ -157,7 +157,7 @@ def _create_flask_app(handler: "hints.Handler") -> Flask: return app -def serve_handler_locally( +def serve_handler( handler: "hints.Handler", *args: Any, port: int = 8080, diff --git a/scaleway_functions_python/testing/__init__.py b/scaleway_functions_python/testing/__init__.py deleted file mode 100644 index 9417d2f..0000000 --- a/scaleway_functions_python/testing/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .serving import serve_handler_locally diff --git a/tests/test_testing/__init__.py b/tests/test_local/__init__.py similarity index 100% rename from tests/test_testing/__init__.py rename to tests/test_local/__init__.py diff --git a/tests/test_testing/test_event.py b/tests/test_local/test_event.py similarity index 95% rename from tests/test_testing/test_event.py rename to tests/test_local/test_event.py index c5c5a1b..37af42e 100644 --- a/tests/test_testing/test_event.py +++ b/tests/test_local/test_event.py @@ -1,7 +1,7 @@ import pytest from flask import Flask, request -from scaleway_functions_python.testing.event import format_http_event +from scaleway_functions_python.local.event import format_http_event @pytest.fixture() diff --git a/tests/test_testing/test_serving.py b/tests/test_local/test_serving.py similarity index 97% rename from tests/test_testing/test_serving.py rename to tests/test_local/test_serving.py index 0b26519..af6895c 100644 --- a/tests/test_testing/test_serving.py +++ b/tests/test_local/test_serving.py @@ -4,7 +4,7 @@ import pytest from flask.testing import FlaskClient -from scaleway_functions_python.testing.serving import _create_flask_app +from scaleway_functions_python.local.serving import _create_flask_app from .. import handlers as h From 2131f7d067a743e9d3d139ad2950d5f91d022240 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Thu, 2 Mar 2023 10:39:37 +0100 Subject: [PATCH 17/18] refactor: add dunder to _format_request_context --- scaleway_functions_python/local/event.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scaleway_functions_python/local/event.py b/scaleway_functions_python/local/event.py index 6a9efb4..d6a8840 100644 --- a/scaleway_functions_python/local/event.py +++ b/scaleway_functions_python/local/event.py @@ -8,7 +8,7 @@ from ..framework.v1.hints import Event, RequestContext -def format_request_context(request: "Request") -> "RequestContext": +def _format_request_context(request: "Request") -> "RequestContext": """Format the request context from the request.""" return { "accountId": "", @@ -24,7 +24,7 @@ def format_request_context(request: "Request") -> "RequestContext": def format_http_event(request: "Request") -> "Event": """Format the event from a generic http request.""" - context = format_request_context(request) + context = _format_request_context(request) body = request.get_data(as_text=True) event: "Event" = { "path": request.path, From 113cda724e7a0ca97a5de7ca32f86133b387644e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andy=20M=C3=A9ry?= Date: Thu, 2 Mar 2023 10:40:56 +0100 Subject: [PATCH 18/18] docs: remove unecessary emoji --- .github/CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index a9234ce..f26d7e6 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -79,4 +79,4 @@ Keep in mind only the **pull request title** will be used as the commit message See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md). -Thank you for reading through all of this, if you have any questions feel free to [reach us](../README.md#📭-reach-us)! +Thank you for reading through all of this, if you have any questions feel free to [reach us](../README.md#reach-us)!